Skip to content

feat: add tracking link schemas for creation, update, and querying#124

Merged
JoachimLK merged 9 commits into
mainfrom
feat/source-tracking
Apr 3, 2026
Merged

feat: add tracking link schemas for creation, update, and querying#124
JoachimLK merged 9 commits into
mainfrom
feat/source-tracking

Conversation

@JoachimLK
Copy link
Copy Markdown
Contributor

@JoachimLK JoachimLK commented Mar 27, 2026

  • Introduced createTrackingLinkSchema and updateTrackingLinkSchema for validating tracking link data.
  • Added trackingLinkIdSchema for route parameter validation.
  • Created trackingLinkQuerySchema for listing tracking links with pagination and filtering options.
  • Implemented sourceStatsQuerySchema for querying source tracking statistics.
  • Added applicationSourceSchema for capturing source attribution from URL query parameters.

Summary

  • What does this PR change?
  • Why is this needed?

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Chore

Validation

  • I tested locally
  • I added/updated relevant documentation
  • I verified multi-tenant scoping and auth behavior for affected API paths

DCO

  • All commits in this PR are signed off (Signed-off-by) via git commit -s

Summary by CodeRabbit

  • New Features

    • Full Source Tracking: dashboard (overview, funnels, trends, top referrers, attribution log) plus per-link detail views and KPIs
    • Create/copy/activate/deactivate/delete tracking links with public tracking URLs and CVR metrics
    • Job publish distribution hub for channel & custom tracking links
    • Apply flow preserves marketing query params and records source attribution
    • Candidate/Job timeline panels showing activity history
  • Bug Fixes

    • Removed “Soon” badge from Source Tracking navigation
  • Tests

    • E2E spec verifying query-parameter propagation and attribution during apply flow
  • Chores

    • Reduced noisy server exception autocapture

- Introduced `createTrackingLinkSchema` and `updateTrackingLinkSchema` for validating tracking link data.
- Added `trackingLinkIdSchema` for route parameter validation.
- Created `trackingLinkQuerySchema` for listing tracking links with pagination and filtering options.
- Implemented `sourceStatsQuerySchema` for querying source tracking statistics.
- Added `applicationSourceSchema` for capturing source attribution from URL query parameters.
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-124 March 27, 2026 08:40 Destroyed
@railway-app
Copy link
Copy Markdown

railway-app Bot commented Mar 27, 2026

🚅 Deployed to the reqcore-pr-124 environment in applirank

Service Status Web Updated (UTC)
applirank ✅ Success (View Logs) Apr 3, 2026 at 4:24 pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 27, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a full Source Tracking feature: DB migration and Drizzle schema, server APIs for tracking links, stats, public redirects and attribution, frontend pages/composables/UI for managing links and analytics, job-listing query propagation, tests, and permission updates.

Changes

Cohort / File(s) Summary
Frontend: nav, pages & job flows
app/components/AppTopBar.vue, app/pages/dashboard/source-tracking/index.vue, app/pages/dashboard/source-tracking/[id].vue, app/pages/dashboard/source-tracking.vue, app/pages/dashboard/index.vue, app/pages/dashboard/jobs/new.vue, app/pages/jobs/index.vue, app/pages/jobs/[slug]/index.vue, app/pages/jobs/[slug]/apply.vue
Removed “coming soon” badge for Source Tracking; added Source Tracking pages (overview, links, link detail), job publish distribution UI, and propagated ref/utm_* through listings→detail→apply; apply page now includes attribution fields in submissions.
Frontend: composables & components
app/composables/useSourceTracking.ts, app/components/CandidateDetailSidebar.vue
Added useSourceTracking and useTrackingLinks composables for stats and link CRUD; added timeline tab with lazy-loaded candidate timeline in sidebar.
Frontend: tests
e2e/critical-flows/source-tracking.spec.ts, tests/unit/candidate-timeline.test.ts
Added Playwright E2E spec verifying query propagation and attribution on apply; added unit tests for candidate timeline logic and schema.
Server: public tracking & application attribution
server/api/public/track/[code].get.ts, server/api/public/jobs/[slug]/apply.post.ts
New public redirect endpoint resolving tracking code (increments clickCount async and redirects with ref); application POST accepts ref/UTM fields and records application_source, best-effort resolves tracking link and increments application_count.
Server: tracking-links CRUD & stats
server/api/tracking-links/index.post.ts, server/api/tracking-links/index.get.ts, server/api/tracking-links/[id].get.ts, server/api/tracking-links/[id].patch.ts, server/api/tracking-links/[id].delete.ts, server/api/tracking-links/[id]/stats.get.ts, server/api/source-tracking/stats.get.ts
Added permission-guarded CRUD endpoints and analytics endpoints returning channel breakdowns, funnels, trends, top links, recent attributed apps, and referrer domains.
Server: activity log timeline
server/api/activity-log/candidate-timeline.get.ts
New API to fetch candidate timeline combining candidate and related application activity with actor/job enrichment.
Database: migration & schema
server/database/migrations/0018_source_tracking.sql, server/database/migrations/meta/_journal.json, server/database/schema/app.ts
Added source_channel enum, tracking_link and application_source tables, indexes/constraints, migration journal entry, and Drizzle schema/relations.
Validation & permissions
server/utils/schemas/trackingLink.ts, server/utils/schemas/publicApplication.ts, shared/permissions.ts
New Zod schemas for tracking links/stats queries and application attribution; extended public application schema with ref/UTM fields; added sourceTracking permissions to roles.
Instrumentation & config
nuxt.config.ts, server/plugins/posthog.ts
Disabled PostHog server autocapture; added plugin error hook that filters 404s before capturing exceptions.
Misc
.gitignore
Ignored generated Snyk instructions file.

Sequence Diagram(s)

sequenceDiagram
    participant Visitor as Visitor (browser)
    participant PublicTrack as Server: GET /api/public/track/:code
    participant DB as Database
    participant Redirect as Browser (redirect target)

    Visitor->>PublicTrack: GET /api/public/track/:code
    PublicTrack->>DB: SELECT tracking_link WHERE code = :code
    alt link found and active
        DB-->>PublicTrack: tracking_link + job.slug
        PublicTrack->>DB: UPDATE tracking_link SET click_count = click_count + 1 (async)
        PublicTrack->>Redirect: 302 -> /jobs/:slug/apply?ref=:code
        Redirect-->>Visitor: loads apply page with ref param
    else not found
        PublicTrack-->>Visitor: 404 Not Found
    end
Loading
sequenceDiagram
    participant Candidate as Candidate (browser)
    participant ApplyClient as Client: Apply page JS
    participant API as Server: POST /api/public/jobs/:slug/apply
    participant DB as Database
    participant Attr as Server: attribution logic

    Candidate->>ApplyClient: Submit application (includes ref, utm_*)
    ApplyClient->>API: POST body or FormData with attribution fields
    API->>DB: INSERT application
    API->>Attr: attempt attribution (resolve tracking link by ref, extract referrer/domain)
    alt tracking link found
        Attr->>DB: UPDATE tracking_link.application_count += 1 (best-effort)
        Attr->>DB: INSERT application_source with tracking_link_id + utm fields + channel
    else fallback
        Attr->>DB: determine channel from UTM/referrer and INSERT application_source
    end
    DB-->>API: OK
    API-->>ApplyClient: 200/201 application confirmation
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰
I hop along each tiny trace,
A ref, a utm—each little place.
I count the clicks and follow trails,
I nibble crumbs and leave soft tails.
Hooray for links and tracked-down trails!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description lists the schemas added but lacks a summary section explaining why these changes are needed and what problem they solve. Add a 'Summary' section explaining the purpose of tracking link schemas and how they support source tracking and attribution functionality.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: introducing tracking link schemas for creation, update, and querying.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/source-tracking

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (8)
server/utils/schemas/trackingLink.ts (1)

69-76: Consider extracting shared UTM field definitions to reduce duplication.

The UTM fields (utmSource, utmMedium, utmCampaign, utmTerm, utmContent) are duplicated across createTrackingLinkSchema, updateTrackingLinkSchema, and applicationSourceSchema. Extracting a shared partial schema would improve maintainability.

♻️ Proposed refactor to reduce duplication
+/** Shared UTM field definitions */
+const utmFieldsSchema = z.object({
+  utmSource: z.string().max(200).optional(),
+  utmMedium: z.string().max(200).optional(),
+  utmCampaign: z.string().max(200).optional(),
+  utmTerm: z.string().max(200).optional(),
+  utmContent: z.string().max(200).optional(),
+})
+
 /** Schema for creating a tracking link */
 export const createTrackingLinkSchema = z.object({
   jobId: z.string().min(1).optional(),
   channel: sourceChannelSchema.default('custom'),
   name: z.string().min(1, 'Name is required').max(200),
-  utmSource: z.string().max(200).optional(),
-  utmMedium: z.string().max(200).optional(),
-  utmCampaign: z.string().max(200).optional(),
-  utmTerm: z.string().max(200).optional(),
-  utmContent: z.string().max(200).optional(),
-})
+}).merge(utmFieldsSchema)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/schemas/trackingLink.ts` around lines 69 - 76, The UTM fields
are duplicated across createTrackingLinkSchema, updateTrackingLinkSchema, and
applicationSourceSchema; create a shared Zod partial (e.g., utmFieldsSchema =
z.object({ utmSource: z.string().max(200).optional(), utmMedium:
z.string().max(200).optional(), utmCampaign: z.string().max(200).optional(),
utmTerm: z.string().max(200).optional(), utmContent:
z.string().max(200).optional() })) and replace the repeated field definitions by
merging or extending that partial into applicationSourceSchema and into the
definitions used by createTrackingLinkSchema and updateTrackingLinkSchema (use
.merge() or spread with .extend() as appropriate) so all three schemas reference
the single utmFieldsSchema.
server/api/tracking-links/index.get.ts (1)

1-1: Remove unused import.

The sql import from drizzle-orm is not used in this file.

🧹 Proposed fix
-import { eq, and, desc, sql } from 'drizzle-orm'
+import { eq, and, desc } from 'drizzle-orm'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/tracking-links/index.get.ts` at line 1, The import list in
server/api/tracking-links/index.get.ts includes an unused symbol `sql` from
'drizzle-orm'; remove `sql` from the import statement (leave `eq`, `and`,
`desc`) to clean up unused imports and avoid lint warnings, updating the import
that currently reads like "import { eq, and, desc, sql } from 'drizzle-orm'".
server/api/public/jobs/[slug]/apply.post.ts (1)

740-743: Minor: Redundant exact-match check in suffix matching loop.

Line 740 already handles exact matches (if (mapping[d])), so the d === key check in line 742 will never be true.

🧹 Proposed simplification
   // Check for exact match first, then suffix match for subdomains
   if (mapping[d]) return mapping[d]!
   for (const [key, channel] of Object.entries(mapping)) {
-    if (d.endsWith(`.${key}`) || d === key) return channel
+    if (d.endsWith(`.${key}`)) return channel
   }
   return null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/public/jobs/`[slug]/apply.post.ts around lines 740 - 743, The loop
that finds a channel for a domain redundantly checks exact equality (d === key)
even though the exact match is already handled by the earlier if (mapping[d])
return mapping[d]!, so update the loop in the domain-to-channel lookup to only
test suffix matches (i.e., remove the d === key branch) by changing the for
(const [key, channel] of Object.entries(mapping)) { if (d.endsWith(`.${key}`))
return channel } so exact matches remain handled by mapping[d] and the loop only
handles subdomain suffixes.
server/api/source-tracking/stats.get.ts (2)

37-159: Consider adding a composite index for performance.

The 8 concurrent queries all filter by organization_id with optional created_at date ranges. The existing indexes (per context snippet 3) don't include a composite (organization_id, created_at) index on applicationSource, which would significantly improve query performance for date-filtered analytics.

Consider adding a migration with:

CREATE INDEX "application_source_org_created_idx" ON "application_source" USING btree ("organization_id", "created_at");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/source-tracking/stats.get.ts` around lines 37 - 159, The queries
against applicationSource (used in the Promise.all block: channelBreakdown,
dailyTrend, recentAttributed, topReferrerDomains, and the total
tracked/untracked calculations) filter by organizationId and createdAt and need
a composite index to speed date-filtered analytics; add a new DB migration that
creates a btree composite index on (organization_id, created_at) for the
application_source table (suggest a name like
application_source_org_created_idx), run/verify the migration, and update any
migration manifests so production/db CI applies it.

134-142: Type the raw SQL query result.

Using any for the query result bypasses type checking.

🧹 Proposed fix
     // 7. Total untracked applications (no source record)
-    db.execute(sql`
+    db.execute<{ count: string }>(sql`
       SELECT count(*) as count
       FROM ${application} a
       WHERE a.organization_id = ${orgId}
         AND NOT EXISTS (
           SELECT 1 FROM ${applicationSource} s
           WHERE s.application_id = a.id
         )
-    `).then((r: any) => Number(r[0]?.count ?? 0)),
+    `).then((r) => Number(r.rows[0]?.count ?? 0)),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/source-tracking/stats.get.ts` around lines 134 - 142, The query
result is typed as any which bypasses type checking; update the db.execute call
for the COUNT query so it returns a typed array of rows (e.g., db.execute<{
count: number }[]>(sql`...`)) and then use that typed result in the mapping
(replace the (r: any) parameter with a correctly typed parameter) so
Number(r[0]?.count ?? 0) is type-safe; target the db.execute(sql`...`)
invocation in stats.get.ts to implement this change.
server/api/public/track/[code].get.ts (1)

38-44: Consider URL encoding the code parameter.

The code is appended directly to the query string without encoding. While the code is base64url (URL-safe), if future code generation changes or if malformed codes reach this point, it could cause issues.

🛡️ Proposed fix
   const baseUrl = env.BETTER_AUTH_URL || `https://${getHeader(event, 'host')}`
   const targetPath = link.job?.slug
-    ? `/jobs/${link.job.slug}/apply?ref=${code}`
-    : `/jobs?ref=${code}`
+    ? `/jobs/${link.job.slug}/apply?ref=${encodeURIComponent(code)}`
+    : `/jobs?ref=${encodeURIComponent(code)}`

   return sendRedirect(event, `${baseUrl}${targetPath}`, 302)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/public/track/`[code].get.ts around lines 38 - 44, The redirect
builds targetPath using the raw code and appends it to the query string, which
can break if code contains unsafe characters; update the construction of the
targetPath (used before calling sendRedirect) to URL-encode the code (e.g., via
encodeURIComponent) so the value of code is safely included in the query param;
ensure you update the expression that references code (where targetPath is
computed) so sendRedirect(event, `${baseUrl}${targetPath}`, 302) receives a path
with the encoded code.
server/api/tracking-links/index.post.ts (1)

48-54: Consider handling code collision on insert.

The generated code has 48 bits of entropy, making collisions unlikely but possible at scale. The database has a UNIQUE constraint on code, so a collision will cause a 500 error rather than a graceful retry.

For robustness, consider adding retry logic or using ON CONFLICT DO NOTHING with a fallback.

🔄 Proposed fix with retry logic
+const MAX_CODE_RETRIES = 3
+
 export default defineEventHandler(async (event) => {
   const session = await requirePermission(event, { sourceTracking: ['create'] })
   const orgId = session.session.activeOrganizationId
   const userId = session.user.id

   const body = await readValidatedBody(event, createTrackingLinkSchema.parse)

   // If scoped to a job, verify the job belongs to this org
   if (body.jobId) {
     const existingJob = await db.query.job.findFirst({
       where: and(eq(job.id, body.jobId), eq(job.organizationId, orgId)),
       columns: { id: true },
     })
     if (!existingJob) {
       throw createError({ statusCode: 404, statusMessage: 'Job not found' })
     }
   }

-  // Generate a unique short code (8 chars, URL-safe)
-  const code = generateTrackingCode()
-
-  const [created] = await db.insert(trackingLink).values({
-    organizationId: orgId,
-    jobId: body.jobId ?? null,
-    channel: body.channel,
-    name: body.name,
-    code,
-    utmSource: body.utmSource ?? null,
-    utmMedium: body.utmMedium ?? null,
-    utmCampaign: body.utmCampaign ?? null,
-    utmTerm: body.utmTerm ?? null,
-    utmContent: body.utmContent ?? null,
-    createdById: userId,
-  }).returning()
+  // Generate a unique short code with retry on collision
+  let created
+  for (let attempt = 0; attempt < MAX_CODE_RETRIES; attempt++) {
+    const code = generateTrackingCode()
+    try {
+      const [result] = await db.insert(trackingLink).values({
+        organizationId: orgId,
+        jobId: body.jobId ?? null,
+        channel: body.channel,
+        name: body.name,
+        code,
+        utmSource: body.utmSource ?? null,
+        utmMedium: body.utmMedium ?? null,
+        utmCampaign: body.utmCampaign ?? null,
+        utmTerm: body.utmTerm ?? null,
+        utmContent: body.utmContent ?? null,
+        createdById: userId,
+      }).returning()
+      created = result
+      break
+    } catch (err: any) {
+      // Retry only on unique constraint violation
+      if (err?.code !== '23505' || attempt === MAX_CODE_RETRIES - 1) {
+        throw err
+      }
+    }
+  }

   setResponseStatus(event, 201)
   return created
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/tracking-links/index.post.ts` around lines 48 - 54, The
generateTrackingCode function can produce collisions; update the POST insert
flow to handle UNIQUE constraint violations by retrying: wrap the insert that
uses generateTrackingCode in a bounded retry loop (e.g., 3–5 attempts),
generating a new code each attempt and breaking on success, and if your DB
client supports it prefer an idempotent pattern like INSERT ... ON CONFLICT DO
NOTHING followed by a SELECT/RETRY if no row was created; specifically catch the
unique-constraint error from your DB client and only retry on that error
(otherwise surface other errors). Ensure you reference generateTrackingCode when
regenerating codes and put limits so you return a 500 or a clear error after
retries are exhausted.
server/database/migrations/0018_source_tracking.sql (1)

44-48: Add an org+created_at index for the stats queries.

The new dashboard filters attribution by organization and date window, but this migration only adds single-column indexes. application_source (organization_id, created_at) will keep those range scans from degrading as the table grows.

Suggested fix
 CREATE INDEX "application_source_organization_id_idx" ON "application_source" USING btree ("organization_id");--> statement-breakpoint
+CREATE INDEX "application_source_organization_created_at_idx" ON "application_source" USING btree ("organization_id", "created_at" DESC);--> statement-breakpoint
 CREATE INDEX "application_source_application_id_idx" ON "application_source" USING btree ("application_id");--> statement-breakpoint
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/database/migrations/0018_source_tracking.sql` around lines 44 - 48,
Add a composite btree index on application_source for (organization_id,
created_at) to support org+date range queries used by the dashboard;
specifically add a statement creating index
"application_source_organization_created_at_idx" ON "application_source" USING
btree ("organization_id", "created_at") alongside the existing single-column
indexes so stats queries avoid full scans as the table grows.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/composables/useSourceTracking.ts`:
- Around line 122-136: The cache key passed to useFetch in the
useSourceTracking.ts useFetch call is made static by using `.value` on the
computed key; change the key to the computed ref itself so it stays reactive
(remove the `.value`), i.e., update the useFetch call that defines key:
computed(() => `tracking-links-${jobId.value ?? 'all'}-${channel.value ??
'all'}`) instead of using `.value`, so the key reacts to jobId and channel
changes and triggers refetches for functions/variables like useFetch, jobId,
channel and the surrounding useSourceTracking / useTrackingLinks logic.
- Around line 76-86: The cache key for the useFetch call in useSourceTracking.ts
is being made static by calling .value on the computed key; update the key
argument to pass the computed Ref itself (e.g., key: computed(() =>
`source-stats-${jobId.value ?? 'all'}-${from.value ?? ''}-${to.value ?? ''}`))
instead of `.value` so the key remains reactive to jobId/from/to changes and
triggers proper refetching (alternatively add a watch on jobId/from/to to call
refresh() on the fetch, but prefer removing `.value` on the computed key).

In `@app/pages/dashboard/source-tracking.vue`:
- Around line 63-72: The empty-state UI is shown whenever links.length === 0
even if the fetch is still in progress; update the template logic that renders
the "Create Your First Tracking Link" block to check linksStatus from
useTrackingLinks() first (e.g. show a loading spinner when linksStatus ===
'loading' or similar), and only fall back to the empty state when linksStatus
indicates success/finished and links.length === 0; apply the same change
wherever the empty-state is rendered (references: useTrackingLinks, links,
linksStatus, refresh/refreshLinks).
- Around line 171-188: The create form's select is missing supported channels
(lever and greenhouse_board) so those sources are only creatable as "custom";
update the options used by the form to include the keys "lever" and
"greenhouse_board" (which already have labels in channelLabels) so they appear
in the select. Locate the options/choices array or computed used by the create
form select (the code that renders the select and the channelLabels constant)
and add entries for lever and greenhouse_board (or ensure the options are
derived from channelLabels so all keys are included).
- Around line 75-79: The jobs list is being capped by the hardcoded query: {
limit: 100 } passed to useFetch ('useFetch' with key 'source-tracking-jobs'),
causing dashboard filters and the create-link modal to miss jobs beyond 100;
remove the fixed limit (or replace it with proper pagination/param-driven
loading) so the component either fetches all jobs or requests pages from the API
and wires the UI to load more—update the useFetch call (key:
'source-tracking-jobs') and related consumers to support unlimited or paginated
results instead of the fixed limit:100.
- Around line 983-1087: The modal Teleport controlled by showCreateModal lacks
proper dialog semantics and keyboard/focus handling; update the modal container
(the element rendered when showCreateModal is true) to include role="dialog",
aria-modal="true", and aria-labelledby pointing to the Create Tracking Link
heading (give the h2 an id), ensure the close button has an accessible label,
implement focus management so when showCreateModal becomes true focus is moved
to the initial focusable element (the input with id "link-name") and focus is
trapped inside the modal while open (restore focus to the previously focused
element on close), and add an Escape key handler to set showCreateModal = false;
apply the same changes to the other dialog referenced at 1092-1119 and ensure
background scrolling is prevented while the dialog is open.
- Around line 853-880: The action buttons are hidden via "opacity-0
group-hover:opacity-100" which makes them undiscoverable to touch and keyboard
users; update the reveal behavior to also respond to keyboard focus by adding
"group-focus-within:opacity-100" (and matching
"group-focus-within:pointer-events-auto") to the div with classes "inline-flex
items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity", and
ensure the row container that has the "group" class is keyboard-focusable (add
tabindex="0" to the row element) so focusing the row via keyboard will trigger
the group-focus-within styles and make the Copy/Toggle/Delete buttons
discoverable; also include "pointer-events-none" on the default state and
"group-hover:pointer-events-auto group-focus-within:pointer-events-auto" on the
div so hidden buttons are not tabbable until revealed.

In `@server/api/source-tracking/stats.get.ts`:
- Around line 20-32: The topLinks, totalTracked, and totalUntracked queries are
missing the job-scoped filters so they ignore the jobId used when building
whereClause; update the queries that compute topLinks, totalTracked, and
totalUntracked to include the same whereClause (or apply and(...dateConditions)
there) so they are scoped consistently with the other metrics (use the existing
whereClause variable when building those queries rather than omitting it).

In `@server/database/migrations/0018_source_tracking.sql`:
- Around line 7-13: The migration currently stores only tracking_link_id on
application_source and relies on a FK that NULLs on DELETE, which loses per-link
attribution; update the schema and deletion strategy so link identity is
preserved by either (a) making deletions soft on the tracking_link table
(add/require an is_active boolean and stop issuing hard deletes from
tracking_link), or (b) snapshotting link identifying fields onto
application_source (add columns like tracking_link_code and tracking_link_name
to application_source and populate them when inserting/updating
application_source and before any tracking_link deletion), and update any
FK/constraints referencing tracking_link to accommodate the chosen approach;
refer to the tracking_link and application_source tables and the
tracking_link_id column when making these changes.
- Line 32: The migration currently makes created_by_id non-null and ties it to a
FK with ON DELETE CASCADE so deleting a user removes tracking links and breaks
application_source references; change created_by_id to allow NULL and alter its
foreign key to use ON DELETE SET NULL (or NO ACTION) instead of CASCADE so
removing the creator does not delete the tracking link or clear references —
update the created_by_id column definition and the FK constraint(s) that
reference created_by_id and also apply the same change to the other occurrence
affecting application_source.

---

Nitpick comments:
In `@server/api/public/jobs/`[slug]/apply.post.ts:
- Around line 740-743: The loop that finds a channel for a domain redundantly
checks exact equality (d === key) even though the exact match is already handled
by the earlier if (mapping[d]) return mapping[d]!, so update the loop in the
domain-to-channel lookup to only test suffix matches (i.e., remove the d === key
branch) by changing the for (const [key, channel] of Object.entries(mapping)) {
if (d.endsWith(`.${key}`)) return channel } so exact matches remain handled by
mapping[d] and the loop only handles subdomain suffixes.

In `@server/api/public/track/`[code].get.ts:
- Around line 38-44: The redirect builds targetPath using the raw code and
appends it to the query string, which can break if code contains unsafe
characters; update the construction of the targetPath (used before calling
sendRedirect) to URL-encode the code (e.g., via encodeURIComponent) so the value
of code is safely included in the query param; ensure you update the expression
that references code (where targetPath is computed) so sendRedirect(event,
`${baseUrl}${targetPath}`, 302) receives a path with the encoded code.

In `@server/api/source-tracking/stats.get.ts`:
- Around line 37-159: The queries against applicationSource (used in the
Promise.all block: channelBreakdown, dailyTrend, recentAttributed,
topReferrerDomains, and the total tracked/untracked calculations) filter by
organizationId and createdAt and need a composite index to speed date-filtered
analytics; add a new DB migration that creates a btree composite index on
(organization_id, created_at) for the application_source table (suggest a name
like application_source_org_created_idx), run/verify the migration, and update
any migration manifests so production/db CI applies it.
- Around line 134-142: The query result is typed as any which bypasses type
checking; update the db.execute call for the COUNT query so it returns a typed
array of rows (e.g., db.execute<{ count: number }[]>(sql`...`)) and then use
that typed result in the mapping (replace the (r: any) parameter with a
correctly typed parameter) so Number(r[0]?.count ?? 0) is type-safe; target the
db.execute(sql`...`) invocation in stats.get.ts to implement this change.

In `@server/api/tracking-links/index.get.ts`:
- Line 1: The import list in server/api/tracking-links/index.get.ts includes an
unused symbol `sql` from 'drizzle-orm'; remove `sql` from the import statement
(leave `eq`, `and`, `desc`) to clean up unused imports and avoid lint warnings,
updating the import that currently reads like "import { eq, and, desc, sql }
from 'drizzle-orm'".

In `@server/api/tracking-links/index.post.ts`:
- Around line 48-54: The generateTrackingCode function can produce collisions;
update the POST insert flow to handle UNIQUE constraint violations by retrying:
wrap the insert that uses generateTrackingCode in a bounded retry loop (e.g.,
3–5 attempts), generating a new code each attempt and breaking on success, and
if your DB client supports it prefer an idempotent pattern like INSERT ... ON
CONFLICT DO NOTHING followed by a SELECT/RETRY if no row was created;
specifically catch the unique-constraint error from your DB client and only
retry on that error (otherwise surface other errors). Ensure you reference
generateTrackingCode when regenerating codes and put limits so you return a 500
or a clear error after retries are exhausted.

In `@server/database/migrations/0018_source_tracking.sql`:
- Around line 44-48: Add a composite btree index on application_source for
(organization_id, created_at) to support org+date range queries used by the
dashboard; specifically add a statement creating index
"application_source_organization_created_at_idx" ON "application_source" USING
btree ("organization_id", "created_at") alongside the existing single-column
indexes so stats queries avoid full scans as the table grows.

In `@server/utils/schemas/trackingLink.ts`:
- Around line 69-76: The UTM fields are duplicated across
createTrackingLinkSchema, updateTrackingLinkSchema, and applicationSourceSchema;
create a shared Zod partial (e.g., utmFieldsSchema = z.object({ utmSource:
z.string().max(200).optional(), utmMedium: z.string().max(200).optional(),
utmCampaign: z.string().max(200).optional(), utmTerm:
z.string().max(200).optional(), utmContent: z.string().max(200).optional() }))
and replace the repeated field definitions by merging or extending that partial
into applicationSourceSchema and into the definitions used by
createTrackingLinkSchema and updateTrackingLinkSchema (use .merge() or spread
with .extend() as appropriate) so all three schemas reference the single
utmFieldsSchema.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9a99ad71-75f6-4dd2-aae5-4f0472f41595

📥 Commits

Reviewing files that changed from the base of the PR and between 5b1c694 and 558e054.

📒 Files selected for processing (19)
  • app/components/AppTopBar.vue
  • app/composables/useSourceTracking.ts
  • app/pages/dashboard/source-tracking.vue
  • app/pages/jobs/[slug]/apply.vue
  • server/api/public/jobs/[slug]/apply.post.ts
  • server/api/public/track/[code].get.ts
  • server/api/source-tracking/stats.get.ts
  • server/api/tracking-links/[id].delete.ts
  • server/api/tracking-links/[id].get.ts
  • server/api/tracking-links/[id].patch.ts
  • server/api/tracking-links/index.get.ts
  • server/api/tracking-links/index.post.ts
  • server/database/migrations/0018_source_tracking.sql
  • server/database/migrations/meta/0018_snapshot.json
  • server/database/migrations/meta/_journal.json
  • server/database/schema/app.ts
  • server/utils/schemas/publicApplication.ts
  • server/utils/schemas/trackingLink.ts
  • shared/permissions.ts

Comment thread app/composables/useSourceTracking.ts Outdated
Comment thread app/composables/useSourceTracking.ts
Comment on lines +63 to +72
const {
links,
total: totalLinks,
fetchStatus: linksStatus,
createLink,
updateLink,
deleteLink,
toggleLink,
refresh: refreshLinks,
} = useTrackingLinks()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Render a loading state before the “no links” empty state.

linksStatus is available, but the tab switches straight to “Create Your First Tracking Link” whenever links.length === 0. If the stats request resolves before useTrackingLinks(), existing links briefly look like they do not exist.

Also applies to: 765-788

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking.vue` around lines 63 - 72, The
empty-state UI is shown whenever links.length === 0 even if the fetch is still
in progress; update the template logic that renders the "Create Your First
Tracking Link" block to check linksStatus from useTrackingLinks() first (e.g.
show a loading spinner when linksStatus === 'loading' or similar), and only fall
back to the empty state when linksStatus indicates success/finished and
links.length === 0; apply the same change wherever the empty-state is rendered
(references: useTrackingLinks, links, linksStatus, refresh/refreshLinks).

Comment on lines +75 to +79
const { data: jobsData } = useFetch('/api/jobs', {
key: 'source-tracking-jobs',
headers: useRequestHeaders(['cookie']),
query: { limit: 100 },
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t cap the job selectors at the first 100 jobs.

This query feeds both the dashboard filter and the create-link modal. Any org with more than 100 jobs loses the ability to filter stats or scope a tracking link to the omitted jobs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking.vue` around lines 75 - 79, The jobs list
is being capped by the hardcoded query: { limit: 100 } passed to useFetch
('useFetch' with key 'source-tracking-jobs'), causing dashboard filters and the
create-link modal to miss jobs beyond 100; remove the fixed limit (or replace it
with proper pagination/param-driven loading) so the component either fetches all
jobs or requests pages from the API and wires the UI to load more—update the
useFetch call (key: 'source-tracking-jobs') and related consumers to support
unlimited or paginated results instead of the fixed limit:100.

Comment thread app/pages/dashboard/source-tracking/index.vue
Comment on lines +853 to +880
<td class="px-4 py-3.5 text-right">
<div class="inline-flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="p-1.5 rounded-lg text-surface-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
title="Copy tracking URL"
@click="copyTrackingUrl(link.code)"
>
<Copy v-if="copiedCode !== link.code" class="size-3.5" />
<CheckCircle2 v-else class="size-3.5 text-green-500" />
</button>
<button
v-if="canManageLinks"
class="p-1.5 rounded-lg text-surface-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
:title="link.isActive ? 'Deactivate' : 'Activate'"
@click="toggleLink(link.id, !link.isActive)"
>
<ToggleRight v-if="link.isActive" class="size-3.5" />
<ToggleLeft v-else class="size-3.5" />
</button>
<button
v-if="canManageLinks"
class="p-1.5 rounded-lg text-surface-400 hover:text-danger-600 dark:hover:text-danger-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
title="Delete"
@click="confirmDelete(link.id)"
>
<Trash2 class="size-3.5" />
</button>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep the row actions discoverable without hover.

These controls only become visible on group-hover. Touch devices never hover, and keyboard users can tab onto fully transparent buttons.

Suggested fix
-                    <div class="inline-flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+                    <div class="inline-flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 transition-opacity">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<td class="px-4 py-3.5 text-right">
<div class="inline-flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="p-1.5 rounded-lg text-surface-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
title="Copy tracking URL"
@click="copyTrackingUrl(link.code)"
>
<Copy v-if="copiedCode !== link.code" class="size-3.5" />
<CheckCircle2 v-else class="size-3.5 text-green-500" />
</button>
<button
v-if="canManageLinks"
class="p-1.5 rounded-lg text-surface-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
:title="link.isActive ? 'Deactivate' : 'Activate'"
@click="toggleLink(link.id, !link.isActive)"
>
<ToggleRight v-if="link.isActive" class="size-3.5" />
<ToggleLeft v-else class="size-3.5" />
</button>
<button
v-if="canManageLinks"
class="p-1.5 rounded-lg text-surface-400 hover:text-danger-600 dark:hover:text-danger-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
title="Delete"
@click="confirmDelete(link.id)"
>
<Trash2 class="size-3.5" />
</button>
</div>
<td class="px-4 py-3.5 text-right">
<div class="inline-flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 transition-opacity">
<button
class="p-1.5 rounded-lg text-surface-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
title="Copy tracking URL"
`@click`="copyTrackingUrl(link.code)"
>
<Copy v-if="copiedCode !== link.code" class="size-3.5" />
<CheckCircle2 v-else class="size-3.5 text-green-500" />
</button>
<button
v-if="canManageLinks"
class="p-1.5 rounded-lg text-surface-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
:title="link.isActive ? 'Deactivate' : 'Activate'"
`@click`="toggleLink(link.id, !link.isActive)"
>
<ToggleRight v-if="link.isActive" class="size-3.5" />
<ToggleLeft v-else class="size-3.5" />
</button>
<button
v-if="canManageLinks"
class="p-1.5 rounded-lg text-surface-400 hover:text-danger-600 dark:hover:text-danger-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
title="Delete"
`@click`="confirmDelete(link.id)"
>
<Trash2 class="size-3.5" />
</button>
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking.vue` around lines 853 - 880, The action
buttons are hidden via "opacity-0 group-hover:opacity-100" which makes them
undiscoverable to touch and keyboard users; update the reveal behavior to also
respond to keyboard focus by adding "group-focus-within:opacity-100" (and
matching "group-focus-within:pointer-events-auto") to the div with classes
"inline-flex items-center gap-1 opacity-0 group-hover:opacity-100
transition-opacity", and ensure the row container that has the "group" class is
keyboard-focusable (add tabindex="0" to the row element) so focusing the row via
keyboard will trigger the group-focus-within styles and make the
Copy/Toggle/Delete buttons discoverable; also include "pointer-events-none" on
the default state and "group-hover:pointer-events-auto
group-focus-within:pointer-events-auto" on the div so hidden buttons are not
tabbable until revealed.

Comment on lines +983 to +1087
<div class="flex items-center justify-between px-6 py-4 border-b border-surface-100 dark:border-surface-800">
<h2 class="text-base font-semibold text-surface-900 dark:text-surface-100">Create Tracking Link</h2>
<button
class="p-1.5 rounded-lg text-surface-400 hover:text-surface-600 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
@click="showCreateModal = false"
>
<X class="size-4" />
</button>
</div>

<!-- Body -->
<form class="px-6 py-5 space-y-4" @submit.prevent="handleCreateLink">
<!-- Name -->
<div>
<label for="link-name" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Link Name</label>
<input
id="link-name"
v-model="newLink.name"
type="text"
placeholder="e.g. LinkedIn Spring Campaign"
class="w-full rounded-xl border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-4 py-2.5 text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all"
/>
</div>

<!-- Channel -->
<div>
<label for="link-channel" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Source Channel</label>
<select
id="link-channel"
v-model="newLink.channel"
class="w-full rounded-xl border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-4 py-2.5 text-sm text-surface-900 dark:text-surface-100 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all"
>
<optgroup label="Job Boards">
<option v-for="ch in ['linkedin', 'indeed', 'glassdoor', 'ziprecruiter', 'monster', 'handshake', 'angellist', 'wellfound', 'dice', 'stackoverflow', 'weworkremotely', 'remoteok', 'builtin', 'hired', 'google_jobs']" :key="ch" :value="ch">{{ getChannelLabel(ch) }}</option>
</optgroup>
<optgroup label="Social Media">
<option v-for="ch in ['facebook', 'twitter', 'instagram', 'tiktok', 'reddit']" :key="ch" :value="ch">{{ getChannelLabel(ch) }}</option>
</optgroup>
<optgroup label="Other">
<option v-for="ch in ['referral', 'career_site', 'email', 'event', 'agency', 'direct', 'custom', 'other']" :key="ch" :value="ch">{{ getChannelLabel(ch) }}</option>
</optgroup>
</select>
</div>

<!-- Job (optional) -->
<div>
<label for="link-job" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Scope to Job <span class="text-surface-400 font-normal">(optional)</span></label>
<select
id="link-job"
v-model="newLink.jobId"
class="w-full rounded-xl border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-4 py-2.5 text-sm text-surface-900 dark:text-surface-100 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all"
>
<option value="">All jobs (org-wide)</option>
<option v-for="j in jobs" :key="j.id" :value="j.id">{{ j.title }}</option>
</select>
</div>

<!-- UTM fields (collapsible) -->
<details class="group">
<summary class="flex items-center gap-2 text-sm font-medium text-surface-500 dark:text-surface-400 cursor-pointer select-none hover:text-surface-700 dark:hover:text-surface-200 transition-colors">
<ChevronDown class="size-4 transition-transform group-open:rotate-180" />
UTM Parameters (optional)
</summary>
<div class="mt-3 grid grid-cols-2 gap-3">
<div>
<label for="utm-source" class="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">utm_source</label>
<input id="utm-source" v-model="newLink.utmSource" type="text" placeholder="linkedin" class="w-full rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-3 py-2 text-xs text-surface-900 dark:text-surface-100 placeholder:text-surface-400 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all" />
</div>
<div>
<label for="utm-medium" class="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">utm_medium</label>
<input id="utm-medium" v-model="newLink.utmMedium" type="text" placeholder="social" class="w-full rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-3 py-2 text-xs text-surface-900 dark:text-surface-100 placeholder:text-surface-400 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all" />
</div>
<div class="col-span-2">
<label for="utm-campaign" class="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">utm_campaign</label>
<input id="utm-campaign" v-model="newLink.utmCampaign" type="text" placeholder="spring-hiring-2026" class="w-full rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-3 py-2 text-xs text-surface-900 dark:text-surface-100 placeholder:text-surface-400 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all" />
</div>
</div>
</details>

<!-- Footer -->
<div class="flex items-center justify-end gap-3 pt-2">
<button
type="button"
class="rounded-xl px-4 py-2.5 text-sm font-medium text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
@click="showCreateModal = false"
>
Cancel
</button>
<button
type="submit"
:disabled="!newLink.name.trim() || isCreating"
class="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm shadow-brand-600/15 transition-all"
>
{{ isCreating ? 'Creating…' : 'Create Link' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

These overlays need real modal accessibility.

Both dialogs are missing dialog semantics, focus trapping/initial focus, and Escape dismissal. That lets focus move behind the overlay and leaves screen readers without a dialog context.

Also applies to: 1092-1119

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking.vue` around lines 983 - 1087, The modal
Teleport controlled by showCreateModal lacks proper dialog semantics and
keyboard/focus handling; update the modal container (the element rendered when
showCreateModal is true) to include role="dialog", aria-modal="true", and
aria-labelledby pointing to the Create Tracking Link heading (give the h2 an
id), ensure the close button has an accessible label, implement focus management
so when showCreateModal becomes true focus is moved to the initial focusable
element (the input with id "link-name") and focus is trapped inside the modal
while open (restore focus to the previously focused element on close), and add
an Escape key handler to set showCreateModal = false; apply the same changes to
the other dialog referenced at 1092-1119 and ensure background scrolling is
prevented while the dialog is open.

Comment on lines +20 to +32
// Build date range conditions
const dateConditions = [eq(applicationSource.organizationId, orgId)]
if (query.jobId) {
dateConditions.push(eq(application.jobId, query.jobId))
}
if (query.from) {
dateConditions.push(gte(applicationSource.createdAt, new Date(query.from)))
}
if (query.to) {
dateConditions.push(lte(applicationSource.createdAt, new Date(query.to)))
}

const whereClause = and(...dateConditions)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the frontend displays these metrics in a way that makes the inconsistency clear
rg -n -C5 'totalTracked|topLinks' app/pages/dashboard/source-tracking.vue

Repository: reqcore-inc/reqcore

Length of output: 2095


🏁 Script executed:

#!/bin/bash
# View the complete stats endpoint to verify which queries use whereClause
cat -n server/api/source-tracking/stats.get.ts | sed -n '20,160p'

Repository: reqcore-inc/reqcore

Length of output: 5930


🏁 Script executed:

#!/bin/bash
# Search for comments around the stats endpoint explaining filter behavior
head -n 50 server/api/source-tracking/stats.get.ts

Repository: reqcore-inc/reqcore

Length of output: 1833


Address filter inconsistency in summary metrics: topLinks, totalTracked, and totalUntracked ignore the jobId filter.

The whereClause (built with optional jobId filter on lines 22-23) is applied to queries 1, 3, 4, 5, and 8, but not to queries 2 (topLinks), 6 (totalTracked), or 7 (totalUntracked). This means when a user filters by job, the summary totals and top links still reflect org-wide data, while other metrics (channel breakdown, daily trend, recent attributed) are correctly scoped.

On the dashboard, these metrics appear together: summary.totalTracked displays alongside filtered trend data, creating inconsistency. Either:

  1. Apply the jobId filter consistently to all queries, or
  2. Document that summary metrics intentionally show org-wide data regardless of job filter
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/source-tracking/stats.get.ts` around lines 20 - 32, The topLinks,
totalTracked, and totalUntracked queries are missing the job-scoped filters so
they ignore the jobId used when building whereClause; update the queries that
compute topLinks, totalTracked, and totalUntracked to include the same
whereClause (or apply and(...dateConditions) there) so they are scoped
consistently with the other metrics (use the existing whereClause variable when
building those queries rather than omitting it).

Comment on lines +7 to +13
"tracking_link_id" text,
"utm_source" text,
"utm_medium" text,
"utm_campaign" text,
"utm_term" text,
"utm_content" text,
"referrer_domain" text,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Hard deletion will erase historical per-link attribution.

application_source only keeps tracking_link_id, and this FK nulls it on delete. Since tracking_link already has is_active, a hard delete permanently drops the link identity the dashboard needs for link-level history. Prefer soft delete, or snapshot the link code/name onto application_source before allowing deletion.

Also applies to: 31-31, 40-40

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/database/migrations/0018_source_tracking.sql` around lines 7 - 13, The
migration currently stores only tracking_link_id on application_source and
relies on a FK that NULLs on DELETE, which loses per-link attribution; update
the schema and deletion strategy so link identity is preserved by either (a)
making deletions soft on the tracking_link table (add/require an is_active
boolean and stop issuing hard deletes from tracking_link), or (b) snapshotting
link identifying fields onto application_source (add columns like
tracking_link_code and tracking_link_name to application_source and populate
them when inserting/updating application_source and before any tracking_link
deletion), and update any FK/constraints referencing tracking_link to
accommodate the chosen approach; refer to the tracking_link and
application_source tables and the tracking_link_id column when making these
changes.

"click_count" integer DEFAULT 0 NOT NULL,
"application_count" integer DEFAULT 0 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_by_id" text NOT NULL,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don’t tie tracking-link lifetime to the creator account.

created_by_id is audit metadata. With ON DELETE CASCADE, offboarding a user deletes active tracking links, breaks their public URLs, and strips related application_source rows of their link reference.

Suggested fix
-	"created_by_id" text NOT NULL,
+	"created_by_id" text,
@@
-ALTER TABLE "tracking_link" ADD CONSTRAINT "tracking_link_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "tracking_link" ADD CONSTRAINT "tracking_link_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint

Also applies to: 43-43

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/database/migrations/0018_source_tracking.sql` at line 32, The
migration currently makes created_by_id non-null and ties it to a FK with ON
DELETE CASCADE so deleting a user removes tracking links and breaks
application_source references; change created_by_id to allow NULL and alter its
foreign key to use ON DELETE SET NULL (or NO ACTION) instead of CASCADE so
removing the creator does not delete the tracking link or clear references —
update the created_by_id column definition and the FK constraint(s) that
reference created_by_id and also apply the same change to the other occurrence
affecting application_source.

- Updated source-tracking.vue to use new permission handling for link management.
- Enhanced job detail page to forward source-tracking query parameters (?ref=, utm_*) to the apply page.
- Modified job listing page to ensure source-tracking parameters are included in navigation to job details.
- Adjusted apply API endpoint to correctly handle and store source-tracking parameters.
- Refactored tracking link API endpoints to correct import paths.
- Added e2e tests to verify that source-tracking parameters propagate correctly from job listing to application submission.
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-124 March 28, 2026 08:28 Destroyed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (5)
app/pages/dashboard/source-tracking.vue (5)

74-78: ⚠️ Potential issue | 🟠 Major

Remove the hard cap of 100 jobs from selector data.

This still truncates job choices for orgs with >100 jobs, which breaks both filtering and create-link scoping.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking.vue` around lines 74 - 78, The useFetch
call for jobs in source-tracking.vue is hard-capped by query: { limit: 100 }
which truncates organizations with more than 100 jobs; remove the limit
parameter from the options passed to useFetch (the const { data: jobsData } =
useFetch('/api/jobs', { key: 'source-tracking-jobs', headers:
useRequestHeaders(['cookie']), query: { limit: 100 }, }) call) so the full job
list is returned (or pass a server-respected parameter like page/offset if you
intend to paginate instead), and verify downstream code that consumes jobsData
(selector, filtering, create-link scoping) still works with the full dataset.

1020-1027: ⚠️ Potential issue | 🟡 Minor

Include all supported channels in the create form options.

lever and greenhouse_board are still missing from selectable create options.

Suggested patch
-                  <option v-for="ch in ['linkedin', 'indeed', 'glassdoor', 'ziprecruiter', 'monster', 'handshake', 'angellist', 'wellfound', 'dice', 'stackoverflow', 'weworkremotely', 'remoteok', 'builtin', 'hired', 'google_jobs']" :key="ch" :value="ch">{{ getChannelLabel(ch) }}</option>
+                  <option v-for="ch in ['linkedin', 'indeed', 'glassdoor', 'ziprecruiter', 'monster', 'handshake', 'angellist', 'wellfound', 'dice', 'stackoverflow', 'weworkremotely', 'remoteok', 'builtin', 'hired', 'lever', 'greenhouse_board', 'google_jobs']" :key="ch" :value="ch">{{ getChannelLabel(ch) }}</option>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking.vue` around lines 1020 - 1027, The create
form's channel <option> lists are missing "lever" and "greenhouse_board"; update
the v-for arrays used for the optgroup options (the arrays feeding the v-for in
the template where getChannelLabel(ch) is used) to include "lever" and
"greenhouse_board" in the appropriate groups so those channels become selectable
in the create form (ensure the strings are added to the same arrays referenced
by the v-for and that getChannelLabel can resolve their labels).

765-787: ⚠️ Potential issue | 🟡 Minor

Gate the links empty state on linksStatus, not only links.length.

The UI can still flash “Create Your First Tracking Link” while links are loading.

Suggested patch
-        <div v-if="links.length === 0" class="flex flex-col items-center justify-center py-20">
+        <div v-if="linksStatus === 'pending'" class="flex items-center justify-center py-20 text-sm text-surface-500 dark:text-surface-400">
+          Loading tracking links...
+        </div>
+        <div v-else-if="links.length === 0" class="flex flex-col items-center justify-center py-20">
           <div class="rounded-3xl border border-surface-200 dark:border-surface-800 bg-white dark:bg-surface-900 p-14 text-center max-w-md shadow-sm">
             ...
           </div>
         </div>

-        <div v-else class="rounded-2xl border border-surface-200/80 dark:border-surface-800 bg-white dark:bg-surface-900 overflow-hidden shadow-xs dark:shadow-none">
+        <div v-else class="rounded-2xl border border-surface-200/80 dark:border-surface-800 bg-white dark:bg-surface-900 overflow-hidden shadow-xs dark:shadow-none">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking.vue` around lines 765 - 787, The
empty-state render is only gated by links.length which lets the "Create Your
First Tracking Link" UI flash while data is loading; update the v-if condition
on the empty-state container to also check the load status (use the existing
linksStatus variable) so the empty state only shows when links are empty AND
linksStatus is not loading (e.g. change v-if="links.length === 0" to
v-if="links.length === 0 && linksStatus !== 'loading'" or to an explicit
success/idle status), leaving the existing v-else block intact and keeping the
canManageLinks and showCreateModal bindings as-is.

982-1086: ⚠️ Potential issue | 🟠 Major

Both modals still need proper dialog semantics and keyboard/focus behavior.

They still lack dialog roles/labels and robust keyboard/focus management (Escape + trap + restore focus).

Suggested patch (semantics + Escape baseline)
-      <div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4">
+      <div
+        v-if="showCreateModal"
+        class="fixed inset-0 z-50 flex items-center justify-center p-4"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="create-tracking-link-title"
+        `@keydown.esc.window`="showCreateModal = false"
+      >
...
-            <h2 class="text-base font-semibold text-surface-900 dark:text-surface-100">Create Tracking Link</h2>
+            <h2 id="create-tracking-link-title" class="text-base font-semibold text-surface-900 dark:text-surface-100">Create Tracking Link</h2>
             <button
               class="p-1.5 rounded-lg text-surface-400 hover:text-surface-600 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
+              aria-label="Close create tracking link dialog"
               `@click`="showCreateModal = false"
             >
...
-      <div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center p-4">
+      <div
+        v-if="showDeleteConfirm"
+        class="fixed inset-0 z-50 flex items-center justify-center p-4"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="delete-tracking-link-title"
+        `@keydown.esc.window`="showDeleteConfirm = false"
+      >
...
-          <h3 class="text-base font-semibold text-surface-900 dark:text-surface-100 mb-2">Delete Tracking Link?</h3>
+          <h3 id="delete-tracking-link-title" class="text-base font-semibold text-surface-900 dark:text-surface-100 mb-2">Delete Tracking Link?</h3>

Also applies to: 1091-1118

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking.vue` around lines 982 - 1086, The modal
Teleport block that uses showCreateModal (around the Create Tracking Link form
and submit handler handleCreateLink) lacks dialog semantics and keyboard/focus
behavior; add role="dialog" and aria-modal="true" on the modal container and
aria-labelledby pointing to the header H2 (give the H2 a stable id), implement
Escape handling to set showCreateModal = false, implement a focus trap when
showCreateModal is true (move tab focus inside the dialog) and set initial focus
to the first input (link-name) while saving and restoring the previously focused
element when the dialog closes; apply the same changes to the other modal block
referenced (lines 1091-1118) so both modals share consistent semantics and
restore focus on close.

853-879: ⚠️ Potential issue | 🟠 Major

Make row actions discoverable without hover.

These controls are still hover-only; touch users and keyboard users get poor discoverability.

Suggested patch
-                    <div class="inline-flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+                    <div class="inline-flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 pointer-events-auto sm:pointer-events-none sm:group-hover:pointer-events-auto sm:group-focus-within:pointer-events-auto transition-opacity">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking.vue` around lines 853 - 879, The row
action buttons are hidden via "opacity-0 group-hover:opacity-100", which
prevents touch and keyboard users from discovering them; update the container
div (currently using copiedCode/canManageLinks, and handlers copyTrackingUrl,
toggleLink, confirmDelete) to make controls visible and focusable by replacing
the hover-only classes with something like a low default opacity plus
hover/focus/focus-within rules (e.g., use "opacity-60 hover:opacity-100
focus-within:opacity-100 focus-visible:opacity-100" or similar) so buttons are
always perceivable on touch and keyboard, while keeping hover/focus styles for
emphasis. Ensure you do not remove the buttons themselves (Copy, CheckCircle2,
ToggleRight/Left, Trash2) and that existing `@click` handlers and v-if conditions
remain unchanged.
🧹 Nitpick comments (3)
app/pages/dashboard/jobs/new.vue (1)

374-394: Missing error handling for malformed API response.

If the /api/tracking-links endpoint returns an unexpected shape (missing code or id), the code will silently fail or produce incorrect URLs. Consider adding validation.

💡 Add response validation
 async function createChannelLink(channel: string, channelName: string) {
   if (createdLinks.value[channel]?.code) return
   createdLinks.value[channel] = { code: '', url: '', loading: true, copied: false }
   try {
     const result = await $fetch<{ id: string; code: string }>('/api/tracking-links', {
       method: 'POST',
       body: {
         jobId: createdJobId.value,
         channel,
         name: `${form.value.title} — ${channelName}`,
       },
     })
+    if (!result?.code) {
+      throw new Error('Invalid response: missing tracking code')
+    }
     const base = `${requestUrl.protocol}//${requestUrl.host}`
     const trackUrl = `${base}/api/public/track/${encodeURIComponent(result.code)}`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/new.vue` around lines 374 - 394, The
createChannelLink function assumes $fetch('/api/tracking-links') returns {id,
code} but doesn't validate the response shape; add explicit validation after the
fetch to ensure result && typeof result.code === 'string' && typeof result.id
=== 'string' (or at least result.code exists) and throw or go to the catch path
if invalid, so you don't build an incorrect trackUrl or set createdLinks with
empty/invalid values; ensure createdLinks.value[channel] is deleted or updated
to loading:false and toast.error is shown when validation fails, and include a
safe guard before using result.code to build trackUrl and calling
track('tracking_link_created').
e2e/critical-flows/source-tracking.spec.ts (1)

130-133: postDataJSON() assumes JSON body, which may fail for multipart submissions.

If the apply form submits with multipart/form-data (e.g., when a resume is uploaded), postDataJSON() will fail. Since this test disables resume requirement, it should work, but the assumption is fragile if test setup changes.

Consider adding a defensive check or documenting this assumption.

💡 Add defensive handling for request body type
     // Verify the POST body included the tracking params
-    const requestBody = applyResponse.request().postDataJSON()
-    expect(requestBody.ref, 'POST body must include ref code').toBe(REF_CODE)
-    expect(requestBody.utmSource, 'POST body must include utmSource').toBe(UTM_SOURCE)
+    const contentType = applyResponse.request().headers()['content-type'] ?? ''
+    if (contentType.includes('application/json')) {
+      const requestBody = applyResponse.request().postDataJSON()
+      expect(requestBody.ref, 'POST body must include ref code').toBe(REF_CODE)
+      expect(requestBody.utmSource, 'POST body must include utmSource').toBe(UTM_SOURCE)
+    } else {
+      // Multipart form — verify via postData() string contains the values
+      const postData = applyResponse.request().postData() ?? ''
+      expect(postData, 'POST body must include ref code').toContain(REF_CODE)
+      expect(postData, 'POST body must include utmSource').toContain(UTM_SOURCE)
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/critical-flows/source-tracking.spec.ts` around lines 130 - 133, The test
assumes JSON by calling applyResponse.request().postDataJSON(), which will throw
for multipart/form-data; update the assertion to first inspect the request
content type (via applyResponse.request().headers()['content-type'] or raw
applyResponse.request().postData()), then: if content-type includes
'application/json' call postDataJSON() and assert requestBody.ref/utmSource,
otherwise parse the raw postData() payload (use URLSearchParams for
x-www-form-urlencoded or extract field boundaries/key-values for
multipart/form-data) and assert the ref and utmSource values; alternatively wrap
postDataJSON() in try/catch and fall back to parsing postData() so the test
remains resilient if the form encoding changes.
server/api/public/jobs/[slug]/apply.post.ts (1)

669-707: Consider returning 'other' for unknown UTM sources instead of null.

When mapUtmToChannel receives an unrecognized utm_source, it returns null, which cascades to the referrer check and potentially falls back to 'direct'. However, if a UTM source is explicitly provided but unrecognized, 'other' (which exists in the enum per context snippet 2) might be more semantically accurate than eventually defaulting to 'direct'.

This is minor since the current fallback chain works correctly, but could improve attribution accuracy.

💡 Optional enhancement
   }
-  return mapping[source] ?? null
+  return mapping[source] ?? 'other'
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/public/jobs/`[slug]/apply.post.ts around lines 669 - 707, The
function mapUtmToChannel currently returns null for unknown but present
utm_source values; change its behavior so that when utmSource is undefined it
still returns null, but when utmSource is provided and not found in the mapping
it returns the semantic fallback 'other' (instead of null) so attribution
doesn't collapse to 'direct'; update mapUtmToChannel to use mapping[source] ??
'other' while keeping the initial if (!utmSource) return null check and
reference the function name mapUtmToChannel and the mapping constant in your
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/pages/dashboard/index.vue`:
- Line 47: Replace the millisecond addition with calendar-safe arithmetic:
create a copy of today and call setDate(getDate() + 7) so weekFromToday advances
by 7 calendar days (respecting DST) instead of adding a fixed millisecond
offset; update the expression that defines weekFromToday (currently using
today.getTime() + 7 * 24 * 60 * 60 * 1000) to use the copy/setDate/getDate
approach to avoid DST shifts.

In `@app/pages/dashboard/jobs/new.vue`:
- Around line 417-448: The client generates channel values like
`custom_{sanitizedName}` in createCustomBoardLink but the server Zod schema
(used by createTrackingLinkSchema) only allows the exact enum 'custom', so
change the payload to send channel: 'custom' (keep the sanitized identifier only
for local uniqueness/UI or include it in the name/metadata) and update the local
duplicate check in createCustomBoardLink to compare the sanitized identifier
(currently stored in the local variable channel) against a different property
(e.g., compare against a new local field like `subChannel` or the `name`/`url`),
ensuring the POST body uses channel: 'custom' while preserving the unique
identifier elsewhere so validation passes and duplicates are still prevented.

---

Duplicate comments:
In `@app/pages/dashboard/source-tracking.vue`:
- Around line 74-78: The useFetch call for jobs in source-tracking.vue is
hard-capped by query: { limit: 100 } which truncates organizations with more
than 100 jobs; remove the limit parameter from the options passed to useFetch
(the const { data: jobsData } = useFetch('/api/jobs', { key:
'source-tracking-jobs', headers: useRequestHeaders(['cookie']), query: { limit:
100 }, }) call) so the full job list is returned (or pass a server-respected
parameter like page/offset if you intend to paginate instead), and verify
downstream code that consumes jobsData (selector, filtering, create-link
scoping) still works with the full dataset.
- Around line 1020-1027: The create form's channel <option> lists are missing
"lever" and "greenhouse_board"; update the v-for arrays used for the optgroup
options (the arrays feeding the v-for in the template where getChannelLabel(ch)
is used) to include "lever" and "greenhouse_board" in the appropriate groups so
those channels become selectable in the create form (ensure the strings are
added to the same arrays referenced by the v-for and that getChannelLabel can
resolve their labels).
- Around line 765-787: The empty-state render is only gated by links.length
which lets the "Create Your First Tracking Link" UI flash while data is loading;
update the v-if condition on the empty-state container to also check the load
status (use the existing linksStatus variable) so the empty state only shows
when links are empty AND linksStatus is not loading (e.g. change
v-if="links.length === 0" to v-if="links.length === 0 && linksStatus !==
'loading'" or to an explicit success/idle status), leaving the existing v-else
block intact and keeping the canManageLinks and showCreateModal bindings as-is.
- Around line 982-1086: The modal Teleport block that uses showCreateModal
(around the Create Tracking Link form and submit handler handleCreateLink) lacks
dialog semantics and keyboard/focus behavior; add role="dialog" and
aria-modal="true" on the modal container and aria-labelledby pointing to the
header H2 (give the H2 a stable id), implement Escape handling to set
showCreateModal = false, implement a focus trap when showCreateModal is true
(move tab focus inside the dialog) and set initial focus to the first input
(link-name) while saving and restoring the previously focused element when the
dialog closes; apply the same changes to the other modal block referenced (lines
1091-1118) so both modals share consistent semantics and restore focus on close.
- Around line 853-879: The row action buttons are hidden via "opacity-0
group-hover:opacity-100", which prevents touch and keyboard users from
discovering them; update the container div (currently using
copiedCode/canManageLinks, and handlers copyTrackingUrl, toggleLink,
confirmDelete) to make controls visible and focusable by replacing the
hover-only classes with something like a low default opacity plus
hover/focus/focus-within rules (e.g., use "opacity-60 hover:opacity-100
focus-within:opacity-100 focus-visible:opacity-100" or similar) so buttons are
always perceivable on touch and keyboard, while keeping hover/focus styles for
emphasis. Ensure you do not remove the buttons themselves (Copy, CheckCircle2,
ToggleRight/Left, Trash2) and that existing `@click` handlers and v-if conditions
remain unchanged.

---

Nitpick comments:
In `@app/pages/dashboard/jobs/new.vue`:
- Around line 374-394: The createChannelLink function assumes
$fetch('/api/tracking-links') returns {id, code} but doesn't validate the
response shape; add explicit validation after the fetch to ensure result &&
typeof result.code === 'string' && typeof result.id === 'string' (or at least
result.code exists) and throw or go to the catch path if invalid, so you don't
build an incorrect trackUrl or set createdLinks with empty/invalid values;
ensure createdLinks.value[channel] is deleted or updated to loading:false and
toast.error is shown when validation fails, and include a safe guard before
using result.code to build trackUrl and calling track('tracking_link_created').

In `@e2e/critical-flows/source-tracking.spec.ts`:
- Around line 130-133: The test assumes JSON by calling
applyResponse.request().postDataJSON(), which will throw for
multipart/form-data; update the assertion to first inspect the request content
type (via applyResponse.request().headers()['content-type'] or raw
applyResponse.request().postData()), then: if content-type includes
'application/json' call postDataJSON() and assert requestBody.ref/utmSource,
otherwise parse the raw postData() payload (use URLSearchParams for
x-www-form-urlencoded or extract field boundaries/key-values for
multipart/form-data) and assert the ref and utmSource values; alternatively wrap
postDataJSON() in try/catch and fall back to parsing postData() so the test
remains resilient if the form encoding changes.

In `@server/api/public/jobs/`[slug]/apply.post.ts:
- Around line 669-707: The function mapUtmToChannel currently returns null for
unknown but present utm_source values; change its behavior so that when
utmSource is undefined it still returns null, but when utmSource is provided and
not found in the mapping it returns the semantic fallback 'other' (instead of
null) so attribution doesn't collapse to 'direct'; update mapUtmToChannel to use
mapping[source] ?? 'other' while keeping the initial if (!utmSource) return null
check and reference the function name mapUtmToChannel and the mapping constant
in your change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 93163356-fce3-4f4a-878f-0533161b18e8

📥 Commits

Reviewing files that changed from the base of the PR and between 558e054 and 60bdc55.

📒 Files selected for processing (12)
  • app/composables/useSourceTracking.ts
  • app/pages/dashboard/index.vue
  • app/pages/dashboard/jobs/new.vue
  • app/pages/dashboard/source-tracking.vue
  • app/pages/jobs/[slug]/index.vue
  • app/pages/jobs/index.vue
  • e2e/critical-flows/source-tracking.spec.ts
  • server/api/public/jobs/[slug]/apply.post.ts
  • server/api/public/track/[code].get.ts
  • server/api/tracking-links/[id].delete.ts
  • server/api/tracking-links/[id].get.ts
  • server/api/tracking-links/[id].patch.ts
✅ Files skipped from review due to trivial changes (1)
  • app/composables/useSourceTracking.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/api/public/track/[code].get.ts

// Truncate to start-of-day so the useFetch key is identical on server & client
// (prevents SSR/hydration mismatch from sub-second timestamp drift)
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const weekFromToday = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify DST edge behavior in JS Date arithmetic.
TZ=America/New_York node <<'NODE'
const today = new Date(2026, 9, 31, 0, 0, 0, 0) // Oct 31, 2026 local
const fixedMs = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000)
const calendar = new Date(today)
calendar.setDate(calendar.getDate() + 7)

console.log('today      :', today.toString())
console.log('fixed ms +7:', fixedMs.toString())
console.log('setDate +7 :', calendar.toString())
NODE

Repository: reqcore-inc/reqcore

Length of output: 275


🏁 Script executed:

git ls-files | grep -E "(dashboard|index\.vue)" | head -20

Repository: reqcore-inc/reqcore

Length of output: 883


🏁 Script executed:

head -60 app/pages/dashboard/index.vue | tail -30

Repository: reqcore-inc/reqcore

Length of output: 921


Use calendar arithmetic instead of fixed milliseconds for +7 days.

At line 47, adding 7 * 24 * 60 * 60 * 1000 milliseconds shifts the boundary by an hour when crossing DST transitions (e.g., Nov 6 23:00 instead of Nov 7 00:00), causing the date range query to include or exclude interviews incorrectly.

🛠️ Proposed fix
-const weekFromToday = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000)
+const weekFromToday = new Date(today)
+weekFromToday.setDate(weekFromToday.getDate() + 7)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const weekFromToday = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000)
const weekFromToday = new Date(today)
weekFromToday.setDate(weekFromToday.getDate() + 7)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/index.vue` at line 47, Replace the millisecond addition
with calendar-safe arithmetic: create a copy of today and call setDate(getDate()
+ 7) so weekFromToday advances by 7 calendar days (respecting DST) instead of
adding a fixed millisecond offset; update the expression that defines
weekFromToday (currently using today.getTime() + 7 * 24 * 60 * 60 * 1000) to use
the copy/setDate/getDate approach to avoid DST shifts.

Comment thread app/pages/dashboard/jobs/new.vue
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-124 March 28, 2026 12:36 Destroyed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/pages/jobs/[slug]/index.vue (1)

12-22: Harden query parameter normalization to handle arrays from repeated params.

On lines 15–20, route.query.* as string assertions can mask array or null values from Vue Router's query parsing. When a URL contains repeated parameters (e.g., ?ref=a&ref=b), route.query returns arrays, and the unsafe cast produces malformed values forwarded to the apply page.

🔧 Suggested hardening
 const applyQuery = computed(() => {
+  const firstQueryValue = (value: unknown): string | undefined => {
+    if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
+    return typeof value === 'string' ? value : undefined
+  }
+
   const q: Record<string, string> = {}
-  if (route.query.ref) q.ref = route.query.ref as string
-  if (route.query.utm_source) q.utm_source = route.query.utm_source as string
-  if (route.query.utm_medium) q.utm_medium = route.query.utm_medium as string
-  if (route.query.utm_campaign) q.utm_campaign = route.query.utm_campaign as string
-  if (route.query.utm_term) q.utm_term = route.query.utm_term as string
-  if (route.query.utm_content) q.utm_content = route.query.utm_content as string
+  const ref = firstQueryValue(route.query.ref)
+  const utmSource = firstQueryValue(route.query.utm_source)
+  const utmMedium = firstQueryValue(route.query.utm_medium)
+  const utmCampaign = firstQueryValue(route.query.utm_campaign)
+  const utmTerm = firstQueryValue(route.query.utm_term)
+  const utmContent = firstQueryValue(route.query.utm_content)
+
+  if (ref) q.ref = ref
+  if (utmSource) q.utm_source = utmSource
+  if (utmMedium) q.utm_medium = utmMedium
+  if (utmCampaign) q.utm_campaign = utmCampaign
+  if (utmTerm) q.utm_term = utmTerm
+  if (utmContent) q.utm_content = utmContent
   return q
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/jobs/`[slug]/index.vue around lines 12 - 22, The computed
applyQuery currently casts route.query.* values to string which breaks when
query params are arrays; update the applyQuery computed function to normalize
each parameter from route.query by checking Array.isArray(route.query.<param>)
and using the first element (or fallback to empty string/undefined) or by
coercing non-string values to string only after this check, ensuring
route.query.ref, utm_source, utm_medium, utm_campaign, utm_term, and utm_content
are safely extracted from arrays/nulls before assigning into the returned q
object; reference the applyQuery computed and route.query usage to locate and
change the normalization logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/pages/jobs/`[slug]/index.vue:
- Around line 12-22: The computed applyQuery currently casts route.query.*
values to string which breaks when query params are arrays; update the
applyQuery computed function to normalize each parameter from route.query by
checking Array.isArray(route.query.<param>) and using the first element (or
fallback to empty string/undefined) or by coercing non-string values to string
only after this check, ensuring route.query.ref, utm_source, utm_medium,
utm_campaign, utm_term, and utm_content are safely extracted from arrays/nulls
before assigning into the returned q object; reference the applyQuery computed
and route.query usage to locate and change the normalization logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7804c548-a9b4-459b-9592-e734e133c0ea

📥 Commits

Reviewing files that changed from the base of the PR and between 60bdc55 and ad98fae.

📒 Files selected for processing (4)
  • app/pages/jobs/[slug]/index.vue
  • app/pages/jobs/index.vue
  • nuxt.config.ts
  • server/plugins/posthog.ts
✅ Files skipped from review due to trivial changes (1)
  • app/pages/jobs/index.vue

…dpoint

- Implemented a new API endpoint for fetching activity log entries related to a specific candidate, including direct events and application-related events.
- Added validation for query parameters using Zod.
- Enriched activity log entries with candidate names and job titles.
- Created a new API endpoint for detailed analytics of tracking links, including metadata, daily trends, application status breakdown, and attributed applications.
- Added unit tests for the candidate timeline query schema and timeline display helpers, ensuring proper validation and functionality.
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-124 March 31, 2026 12:13 Destroyed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (4)
app/pages/dashboard/source-tracking/index.vue (4)

63-72: ⚠️ Potential issue | 🟡 Minor

Wait for linksStatus before rendering link counts or the empty state.

Line 66 already gives you linksStatus, but the Active Links KPI and the “Create Your First Tracking Link” branch still treat an unresolved links request as 0. That briefly shows the wrong count and empty state for orgs that already have links, and it makes fetch failures look like “no data”.

Also applies to: 454-466, 781-802

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking/index.vue` around lines 63 - 72, The
Active Links KPI and the "Create Your First Tracking Link" empty-state are
rendering counts/empty when links are still loading; update the UI to wait for
linksStatus (from useTrackingLinks) to reflect a settled fetch before using
links or totalLinks—e.g., only show the numeric KPI or the "no links" branch
when linksStatus === 'success' (or not 'loading'), otherwise render a
loading/skeleton state or nothing; apply this guard to all places reading
links/totalLinks (the KPI component and the create-first-link branch, plus the
other occurrences referenced) so unresolved or errored fetches don't render as
zero/empty.

75-79: ⚠️ Potential issue | 🟠 Major

Don’t cap the job selectors at the first 100 jobs.

This query feeds both the page-level job filter and the “Scope to Job” selector in the create-link modal. Orgs with more than 100 jobs lose the ability to filter analytics or scope a new link to the omitted jobs. Either remove the fixed cap or page/load more here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking/index.vue` around lines 75 - 79, The
query fetching jobs uses useFetch('/api/jobs', { key: 'source-tracking-jobs',
headers: useRequestHeaders(['cookie']), query: { limit: 100 } }) which
artificially caps results at 100; remove the fixed query.limit or replace it
with a pagination/loading strategy so the page-level job filter and the "Scope
to Job" selector see all jobs (e.g., remove query.limit or implement incremental
loading with page/offset params and a "load more" or fetch-all option in the
component that uses the 'source-tracking-jobs' data).

820-901: ⚠️ Potential issue | 🟠 Major

Keep row actions discoverable without hover.

These buttons only appear on group-hover. Touch users never hover, and keyboard users can tab onto fully transparent controls. Make them visible by default on small screens and reveal them on group-focus-within as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking/index.vue` around lines 820 - 901, The
action buttons container currently uses "opacity-0 group-hover:opacity-100"
which hides controls for touch and keyboard users; update the div with class
"inline-flex items-center gap-1 opacity-0 group-hover:opacity-100
transition-opacity" to make actions visible by default on small screens and also
reveal on group-focus-within: replace its classes with something like
"inline-flex items-center gap-1 opacity-100 sm:opacity-0 group-hover:opacity-100
group-focus-within:opacity-100 transition-opacity" so mobile users see buttons
and keyboard users see them when the row receives focus; verify this change for
the div that contains the Copy/Toggle/Delete buttons (associated with
copiedCode, copyTrackingUrl, toggleLink, confirmDelete and canManageLinks).

1019-1123: ⚠️ Potential issue | 🟠 Major

Both teleported overlays still need real modal accessibility.

The create and delete dialogs are still plain divs: no role="dialog"/aria-modal, no labelled dialog title, no initial focus or focus trap, and no Escape handling. That leaves focus behind the overlay and gives screen readers no dialog context.

Also applies to: 1128-1155

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/source-tracking/index.vue` around lines 1019 - 1123, The
create (Teleport using showCreateModal, form with handleCreateLink, title h2 and
input id="link-name") and delete dialogs must be made accessible: add
role="dialog" and aria-modal="true" on the dialog container and give the title
an id (e.g., id on the h2) and set aria-labelledby to that id; when
showCreateModal/showDeleteModal opens, move initial focus into the dialog (e.g.,
focus the first interactive element like input#link-name or the primary confirm
button) and trap focus inside until closed; add an Escape key handler to close
the modal (set showCreateModal=false / showDeleteModal=false) and restore focus
to the element that opened the dialog; also ensure the background content is
hidden from assistive tech (e.g., set aria-hidden on the main app container or
use inert) while the modal is open.
🧹 Nitpick comments (3)
app/pages/dashboard/ai-analysis.vue (1)

189-283: Consider extracting a reusable stat-card component to reduce duplication.

The five cards share nearly identical structure/classes with only small differences (icon, accent color, value, subtitle). A shared component would make future visual/system changes safer and faster.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/ai-analysis.vue` around lines 189 - 283, The dashboard
has five near-identical stat card blocks; extract a reusable StatCard component
and replace the repeated markup in ai-analysis.vue with it. Create a StatCard
that accepts props like title (e.g., "Total Runs"), value (or a value slot),
icon component, accent classes (for gradient/hover color), small
subtitle/description, and an optional footer slot for conditional content (used
for the pricing block); update usages to pass existing expressions/refs
(formatNumber(summary.totalRuns), successRate, formatCost(totalCost),
summary.completedRuns, summary.failedRuns, pricing.configured, etc.) and
preserve current class/transition behavior by moving conditional :class logic
into props. Ensure the new component exposes the same DOM structure so utilities
like group-hover and absolute icons continue to work.
tests/unit/candidate-timeline.test.ts (1)

8-128: This suite is mostly testing cloned logic, not the feature itself.

querySchema, the timeline helper functions, and the lazy-load/reset behavior are recreated inside the spec instead of imported or exercised through the actual endpoint/component code. A regression in server/api/activity-log/candidate-timeline.get.ts, app/components/CandidateDetailSidebar.vue, or app/pages/dashboard/jobs/[id]/index.vue can therefore leave this file green. Please extract shared schema/helpers into an importable module, or mount the real component/composable and drive the actual watchers.

Also applies to: 259-345

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/candidate-timeline.test.ts` around lines 8 - 128, The test
recreates core logic locally (querySchema, getTimelineActionColor,
describeTimelineItem, makeEntry) instead of importing the canonical
implementations, so extract the shared schema/helpers into a single exportable
module (or import the existing implementations from
server/api/activity-log/candidate-timeline.get.ts and
app/components/CandidateDetailSidebar.vue) and update the test to import
querySchema, getTimelineActionColor, describeTimelineItem and makeEntry from
that module; alternatively, replace the unit-level recreation by mounting the
real component/composable (e.g., CandidateDetailSidebar or the
candidate-timeline composable) in the test and exercising its watchers/endpoint
calls so the spec drives the actual code paths rather than cloned logic.
app/pages/dashboard/jobs/new.vue (1)

1553-1746: Consider rendering the three distribution groups from one shared template/component.

The card body is duplicated almost verbatim for job_board, outreach, and social. Any behavior or copy change now has to be kept in sync in three places. Grouping distributionChannels by category and rendering one reusable section/card would make this flow much cheaper to maintain.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/new.vue` around lines 1553 - 1746, The template
duplicates the same card markup for each category; refactor by grouping
distributionChannels by category and rendering a single reusable component
(e.g., ChannelGroup) or template that accepts props: channels (array),
createdLinks, createChannelLink, copyChannelLink, channelIcons, and defaultIcon
(Globe). Replace the three v-for blocks that filter by c.category ===
'job_board'/'outreach'/'social' with a single loop over
Object.entries(groupedChannels) (or an array of categories) that renders
<ChannelGroup :channels="channels" :created-links="createdLinks"
:channel-icons="channelIcons" `@create`="createChannelLink"
`@copy`="copyChannelLink" />, and move the duplicated UI (input, buttons, loading
states, classes and checks for createdLinks[ch.channel]?.code/copied/loading)
into that component/template so behavior remains identical while removing
repetition.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/components/CandidateDetailSidebar.vue`:
- Around line 361-383: The timeline loader currently only watches activeTab and
can accept stale fetch results when props.applicationId/candidateId changes;
update loadTimeline (and the watcher) to key load/loaded state by the resolved
candidateId: at start of loadTimeline capture const resolvedCandidateId =
candidateId.value, bail if falsy, set a per-request flag (or store
resolvedCandidateId in timelineLoadedForCandidate), then after the fetch verify
candidateId.value === resolvedCandidateId before assigning timelineItems.value,
timelineLoaded.value and clearing timelineError; also update the watch to
trigger when candidateId/applicationId changes (or include candidateId in the
watch) so reopening the timeline for a new candidate forces a new load and
ignores stale responses.

In `@app/pages/dashboard/ai-analysis.vue`:
- Line 191: The large decorative Activity icon component and other decorative
icons (e.g., the Activity instances at the positions noted) should be hidden
from assistive tech by adding aria-hidden="true" to those icon elements;
additionally, for icon-only metric displays such as completedRuns and
failedRuns, add an accessible label (e.g., aria-label or visually-hidden text)
to the metric container or the icon so screen readers see a clear description
(for example, aria-label="Completed runs: X" on the element rendering
completedRuns and similarly for failedRuns) while keeping the visual UI
unchanged; locate the Activity components and the completedRuns/failedRuns
metric render functions in ai-analysis.vue and apply these attribute changes
consistently.

In `@app/pages/dashboard/jobs/`[id]/index.vue:
- Around line 353-377: The timeline load can commit stale data when the
candidate changes because loadTimeline reads resolvedCurrentApplication lazily
and sets timelineItems/timelineLoaded unconditionally; fix by capturing the
candidate id at the start of loadTimeline (use the local candId variable) and
ignore results that don't match that captured id before assigning
timelineItems/timelineLoaded, or alternatively store timeline state keyed by
candidate id (e.g., timelineByCandidate[candId] with its own loaded flag).
Update loadTimeline, timelineLoaded, timelineItems, and the watch on
timelineCandidateId/detailTab to use the per-candidate guard (or request token)
so only responses matching the originally requested candidate update the UI.

In `@app/pages/dashboard/jobs/new.vue`:
- Around line 374-388: Both createChannelLink and createCustomBoardLink are
vulnerable to double-submission because they only guard after a code exists (or
set the creating flag too late); update createChannelLink to check
createdLinks.value[channel]?.code OR createdLinks.value[channel]?.loading at the
very top and set createdLinks.value[channel] = { code: '', url: '', loading:
true, copied: false } immediately before any await so subsequent calls see
loading=true; do the same for createCustomBoardLink by checking
isCreatingCustomBoard at the start, setting isCreatingCustomBoard = true
immediately before the POST, and clear the flags in a finally block (reset
loading or isCreatingCustomBoard on error) so duplicate requests cannot be
queued.

In `@server/api/tracking-links/`[id]/stats.get.ts:
- Around line 30-127: The analytics queries are only scoped by trackingLinkId
and can leak other tenants' data; add org scoping: include
eq(job.organizationId, orgId) in the job lookup inside job.findFirst, and ensure
every analytics query uses the tenant predicate(s) by adding
eq(application.organizationId, orgId) and eq(job.organizationId, orgId) (and
eq(candidate.organizationId, orgId) for the attributedApplications join) into
the shared dateConditions/whereClause (or combine with and(...) in each query)
so statusBreakdown, dailyTrend, attributedApplications, referrerDomains and
totalAttributed are all filtered by the current orgId.

---

Duplicate comments:
In `@app/pages/dashboard/source-tracking/index.vue`:
- Around line 63-72: The Active Links KPI and the "Create Your First Tracking
Link" empty-state are rendering counts/empty when links are still loading;
update the UI to wait for linksStatus (from useTrackingLinks) to reflect a
settled fetch before using links or totalLinks—e.g., only show the numeric KPI
or the "no links" branch when linksStatus === 'success' (or not 'loading'),
otherwise render a loading/skeleton state or nothing; apply this guard to all
places reading links/totalLinks (the KPI component and the create-first-link
branch, plus the other occurrences referenced) so unresolved or errored fetches
don't render as zero/empty.
- Around line 75-79: The query fetching jobs uses useFetch('/api/jobs', { key:
'source-tracking-jobs', headers: useRequestHeaders(['cookie']), query: { limit:
100 } }) which artificially caps results at 100; remove the fixed query.limit or
replace it with a pagination/loading strategy so the page-level job filter and
the "Scope to Job" selector see all jobs (e.g., remove query.limit or implement
incremental loading with page/offset params and a "load more" or fetch-all
option in the component that uses the 'source-tracking-jobs' data).
- Around line 820-901: The action buttons container currently uses "opacity-0
group-hover:opacity-100" which hides controls for touch and keyboard users;
update the div with class "inline-flex items-center gap-1 opacity-0
group-hover:opacity-100 transition-opacity" to make actions visible by default
on small screens and also reveal on group-focus-within: replace its classes with
something like "inline-flex items-center gap-1 opacity-100 sm:opacity-0
group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity" so
mobile users see buttons and keyboard users see them when the row receives
focus; verify this change for the div that contains the Copy/Toggle/Delete
buttons (associated with copiedCode, copyTrackingUrl, toggleLink, confirmDelete
and canManageLinks).
- Around line 1019-1123: The create (Teleport using showCreateModal, form with
handleCreateLink, title h2 and input id="link-name") and delete dialogs must be
made accessible: add role="dialog" and aria-modal="true" on the dialog container
and give the title an id (e.g., id on the h2) and set aria-labelledby to that
id; when showCreateModal/showDeleteModal opens, move initial focus into the
dialog (e.g., focus the first interactive element like input#link-name or the
primary confirm button) and trap focus inside until closed; add an Escape key
handler to close the modal (set showCreateModal=false / showDeleteModal=false)
and restore focus to the element that opened the dialog; also ensure the
background content is hidden from assistive tech (e.g., set aria-hidden on the
main app container or use inert) while the modal is open.

---

Nitpick comments:
In `@app/pages/dashboard/ai-analysis.vue`:
- Around line 189-283: The dashboard has five near-identical stat card blocks;
extract a reusable StatCard component and replace the repeated markup in
ai-analysis.vue with it. Create a StatCard that accepts props like title (e.g.,
"Total Runs"), value (or a value slot), icon component, accent classes (for
gradient/hover color), small subtitle/description, and an optional footer slot
for conditional content (used for the pricing block); update usages to pass
existing expressions/refs (formatNumber(summary.totalRuns), successRate,
formatCost(totalCost), summary.completedRuns, summary.failedRuns,
pricing.configured, etc.) and preserve current class/transition behavior by
moving conditional :class logic into props. Ensure the new component exposes the
same DOM structure so utilities like group-hover and absolute icons continue to
work.

In `@app/pages/dashboard/jobs/new.vue`:
- Around line 1553-1746: The template duplicates the same card markup for each
category; refactor by grouping distributionChannels by category and rendering a
single reusable component (e.g., ChannelGroup) or template that accepts props:
channels (array), createdLinks, createChannelLink, copyChannelLink,
channelIcons, and defaultIcon (Globe). Replace the three v-for blocks that
filter by c.category === 'job_board'/'outreach'/'social' with a single loop over
Object.entries(groupedChannels) (or an array of categories) that renders
<ChannelGroup :channels="channels" :created-links="createdLinks"
:channel-icons="channelIcons" `@create`="createChannelLink"
`@copy`="copyChannelLink" />, and move the duplicated UI (input, buttons, loading
states, classes and checks for createdLinks[ch.channel]?.code/copied/loading)
into that component/template so behavior remains identical while removing
repetition.

In `@tests/unit/candidate-timeline.test.ts`:
- Around line 8-128: The test recreates core logic locally (querySchema,
getTimelineActionColor, describeTimelineItem, makeEntry) instead of importing
the canonical implementations, so extract the shared schema/helpers into a
single exportable module (or import the existing implementations from
server/api/activity-log/candidate-timeline.get.ts and
app/components/CandidateDetailSidebar.vue) and update the test to import
querySchema, getTimelineActionColor, describeTimelineItem and makeEntry from
that module; alternatively, replace the unit-level recreation by mounting the
real component/composable (e.g., CandidateDetailSidebar or the
candidate-timeline composable) in the test and exercising its watchers/endpoint
calls so the spec drives the actual code paths rather than cloned logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 58f1e660-7305-4492-936b-13287242ec72

📥 Commits

Reviewing files that changed from the base of the PR and between ad98fae and 46e1e15.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (12)
  • .gitignore
  • app/components/CandidateDetailSidebar.vue
  • app/composables/useSourceTracking.ts
  • app/pages/dashboard/ai-analysis.vue
  • app/pages/dashboard/index.vue
  • app/pages/dashboard/jobs/[id]/index.vue
  • app/pages/dashboard/jobs/new.vue
  • app/pages/dashboard/source-tracking/[id].vue
  • app/pages/dashboard/source-tracking/index.vue
  • server/api/activity-log/candidate-timeline.get.ts
  • server/api/tracking-links/[id]/stats.get.ts
  • tests/unit/candidate-timeline.test.ts
✅ Files skipped from review due to trivial changes (2)
  • .gitignore
  • app/composables/useSourceTracking.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/pages/dashboard/index.vue

Comment on lines +361 to +383
async function loadTimeline() {
if (!candidateId.value) return
timelineLoading.value = true
timelineError.value = null
try {
const result = await $fetch<{ items: TimelineEntry[] }>('/api/activity-log/candidate-timeline', {
query: { candidateId: candidateId.value },
})
timelineItems.value = result.items
timelineLoaded.value = true
} catch (err: any) {
timelineError.value = err?.data?.statusMessage ?? 'Failed to load timeline'
} finally {
timelineLoading.value = false
}
}

// Load timeline data lazily when tab is selected
watch(activeTab, (tab) => {
if (tab === 'timeline' && !timelineLoaded.value && candidateId.value) {
loadTimeline()
}
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reload the timeline for the active candidate, not just for the active tab.

After props.applicationId changes, application.value can still hold the previous application until useFetch resolves. Because this logic only watches activeTab and unconditionally stores the fetch result, reopening Timeline during that window can show the previous candidate's activity and suppress the follow-up fetch for the new one. Key the load/loaded state by the resolved candidateId, and ignore responses whose candidate no longer matches the active application.

Also applies to: 385-395

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/CandidateDetailSidebar.vue` around lines 361 - 383, The
timeline loader currently only watches activeTab and can accept stale fetch
results when props.applicationId/candidateId changes; update loadTimeline (and
the watcher) to key load/loaded state by the resolved candidateId: at start of
loadTimeline capture const resolvedCandidateId = candidateId.value, bail if
falsy, set a per-request flag (or store resolvedCandidateId in
timelineLoadedForCandidate), then after the fetch verify candidateId.value ===
resolvedCandidateId before assigning timelineItems.value, timelineLoaded.value
and clearing timelineError; also update the watch to trigger when
candidateId/applicationId changes (or include candidateId in the watch) so
reopening the timeline for a new candidate forces a new load and ignores stale
responses.

<div class="absolute inset-0 bg-gradient-to-br from-brand-50/50 to-transparent dark:from-brand-950/20 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
<div class="group relative rounded-2xl bg-white dark:bg-surface-900 p-5 sm:p-6 overflow-hidden isolate ring-1 ring-surface-950/[0.04] dark:ring-white/[0.06] hover:ring-brand-500/25 dark:hover:ring-brand-400/25 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-brand-500/[0.08]">
<div class="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-brand-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<Activity class="absolute -bottom-3 -right-3 size-24 text-brand-500/[0.03] dark:text-brand-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add accessible labels for icon-only metrics and hide decorative icons from AT.

At Line 200-Line 208, completedRuns/failedRuns are now icon + number only, which is ambiguous for screen readers. Also, large decorative icons at Line 191, Line 216, Line 234, Line 250, and Line 266 should be aria-hidden.

♿ Suggested accessibility patch
-          <Activity class="absolute -bottom-3 -right-3 size-24 text-brand-500/[0.03] dark:text-brand-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
+          <Activity aria-hidden="true" class="absolute -bottom-3 -right-3 size-24 text-brand-500/[0.03] dark:text-brand-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />

             <div class="mt-1 flex items-center gap-3 text-[11px]">
               <span class="flex items-center gap-1 text-success-600 dark:text-success-400">
-                <CheckCircle2 class="size-3" />
+                <CheckCircle2 aria-hidden="true" class="size-3" />
+                <span class="sr-only">Completed runs:</span>
                 {{ summary.completedRuns }}
               </span>
               <span v-if="summary.failedRuns > 0" class="flex items-center gap-1 text-danger-600 dark:text-danger-400">
-                <XCircle class="size-3" />
+                <XCircle aria-hidden="true" class="size-3" />
+                <span class="sr-only">Failed runs:</span>
                 {{ summary.failedRuns }}
               </span>
             </div>

-          <TrendingUp class="absolute -bottom-3 -right-3 size-24 text-success-500/[0.03] dark:text-success-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
+          <TrendingUp aria-hidden="true" class="absolute -bottom-3 -right-3 size-24 text-success-500/[0.03] dark:text-success-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
-          <Zap class="absolute -bottom-3 -right-3 size-24 text-violet-500/[0.03] dark:text-violet-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
+          <Zap aria-hidden="true" class="absolute -bottom-3 -right-3 size-24 text-violet-500/[0.03] dark:text-violet-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
-          <Sparkles class="absolute -bottom-3 -right-3 size-24 text-amber-500/[0.03] dark:text-amber-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
+          <Sparkles aria-hidden="true" class="absolute -bottom-3 -right-3 size-24 text-amber-500/[0.03] dark:text-amber-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
-          <DollarSign class="absolute -bottom-3 -right-3 size-24 text-emerald-500/[0.03] dark:text-emerald-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
+          <DollarSign aria-hidden="true" class="absolute -bottom-3 -right-3 size-24 text-emerald-500/[0.03] dark:text-emerald-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />

Also applies to: 200-208, 216-216, 234-234, 250-250, 266-266

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/ai-analysis.vue` at line 191, The large decorative
Activity icon component and other decorative icons (e.g., the Activity instances
at the positions noted) should be hidden from assistive tech by adding
aria-hidden="true" to those icon elements; additionally, for icon-only metric
displays such as completedRuns and failedRuns, add an accessible label (e.g.,
aria-label or visually-hidden text) to the metric container or the icon so
screen readers see a clear description (for example, aria-label="Completed runs:
X" on the element rendering completedRuns and similarly for failedRuns) while
keeping the visual UI unchanged; locate the Activity components and the
completedRuns/failedRuns metric render functions in ai-analysis.vue and apply
these attribute changes consistently.

Comment thread app/pages/dashboard/jobs/[id]/index.vue Outdated
Comment on lines +374 to +388
async function createChannelLink(channel: string, channelName: string) {
if (createdLinks.value[channel]?.code) return
createdLinks.value[channel] = { code: '', url: '', loading: true, copied: false }
try {
const result = await $fetch<{ id: string; code: string }>('/api/tracking-links', {
method: 'POST',
body: {
jobId: createdJobId.value,
channel,
name: `${form.value.title} — ${channelName}`,
},
})
const base = `${requestUrl.protocol}//${requestUrl.host}`
const trackUrl = `${base}/api/public/track/${encodeURIComponent(result.code)}`
createdLinks.value[channel] = { code: result.code, url: trackUrl, loading: false, copied: false }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard both tracking-link creates against double submission.

A rapid double-click or Enter can queue two /api/tracking-links POSTs here because createChannelLink() only blocks once a code exists, and createCustomBoardLink() doesn't check isCreatingCustomBoard before the request starts. That can leave duplicate links for the same surface and split attribution data.

🛡️ Minimal guard to prevent duplicate creates
 async function createChannelLink(channel: string, channelName: string) {
-  if (createdLinks.value[channel]?.code) return
+  if (createdLinks.value[channel]?.loading || createdLinks.value[channel]?.code) return
   createdLinks.value[channel] = { code: '', url: '', loading: true, copied: false }
   try {
 async function createCustomBoardLink() {
+  if (isCreatingCustomBoard.value) return
   const name = customBoardName.value.trim()
   if (!name) return
+  isCreatingCustomBoard.value = true
   // Use a slug derived from the custom board name for local dedup only
   const dedupeKey = `custom_${name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 50)}`

   // Prevent duplicates
   if (customBoardLinks.value.some(l => l.channel === dedupeKey)) {
     toast.warning('Duplicate board', `A custom link for "${name}" already exists.`)
+    isCreatingCustomBoard.value = false
     return
   }
-
-  isCreatingCustomBoard.value = true

Also applies to: 417-447

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/new.vue` around lines 374 - 388, Both
createChannelLink and createCustomBoardLink are vulnerable to double-submission
because they only guard after a code exists (or set the creating flag too late);
update createChannelLink to check createdLinks.value[channel]?.code OR
createdLinks.value[channel]?.loading at the very top and set
createdLinks.value[channel] = { code: '', url: '', loading: true, copied: false
} immediately before any await so subsequent calls see loading=true; do the same
for createCustomBoardLink by checking isCreatingCustomBoard at the start,
setting isCreatingCustomBoard = true immediately before the POST, and clear the
flags in a finally block (reset loading or isCreatingCustomBoard on error) so
duplicate requests cannot be queued.

Comment on lines +30 to +127
// ─── Scoped job title ─────────────────────
let jobTitle: string | null = null
if (link.jobId) {
const j = await db.query.job.findFirst({
where: eq(job.id, link.jobId),
columns: { title: true },
})
jobTitle = j?.title ?? null
}

// ─── Date conditions ──────────────────────
const dateConditions = [eq(applicationSource.trackingLinkId, id)]
if (query.from) {
dateConditions.push(gte(applicationSource.createdAt, new Date(query.from)))
}
if (query.to) {
dateConditions.push(lte(applicationSource.createdAt, new Date(query.to)))
}

const whereClause = and(...dateConditions)

// ─── Run all analytics queries in parallel ─
const [
statusBreakdown,
dailyTrend,
attributedApplications,
referrerDomains,
totalAttributed,
] = await Promise.all([
// 1. Application status breakdown
db
.select({
status: application.status,
count: count().as('count'),
})
.from(applicationSource)
.innerJoin(application, eq(application.id, applicationSource.applicationId))
.where(whereClause)
.groupBy(application.status),

// 2. Daily trend (applications over time)
db
.select({
date: sql<string>`date_trunc('day', ${applicationSource.createdAt})::date`.as('day'),
count: count().as('count'),
})
.from(applicationSource)
.innerJoin(application, eq(application.id, applicationSource.applicationId))
.where(whereClause)
.groupBy(sql`date_trunc('day', ${applicationSource.createdAt})::date`)
.orderBy(sql`date_trunc('day', ${applicationSource.createdAt})::date`),

// 3. All attributed applications with candidate + job info
db
.select({
applicationId: applicationSource.applicationId,
channel: applicationSource.channel,
utmSource: applicationSource.utmSource,
utmMedium: applicationSource.utmMedium,
utmCampaign: applicationSource.utmCampaign,
utmTerm: applicationSource.utmTerm,
utmContent: applicationSource.utmContent,
referrerDomain: applicationSource.referrerDomain,
candidateFirstName: candidate.firstName,
candidateLastName: candidate.lastName,
candidateEmail: candidate.email,
jobTitle: job.title,
jobId: application.jobId,
status: application.status,
appliedAt: applicationSource.createdAt,
})
.from(applicationSource)
.innerJoin(application, eq(application.id, applicationSource.applicationId))
.innerJoin(candidate, eq(candidate.id, application.candidateId))
.innerJoin(job, eq(job.id, application.jobId))
.where(whereClause)
.orderBy(desc(applicationSource.createdAt))
.limit(100),

// 4. Referrer domain breakdown
db
.select({
domain: applicationSource.referrerDomain,
count: count().as('count'),
})
.from(applicationSource)
.innerJoin(application, eq(application.id, applicationSource.applicationId))
.where(and(
...dateConditions,
sql`${applicationSource.referrerDomain} IS NOT NULL`,
))
.groupBy(applicationSource.referrerDomain)
.orderBy(sql`count(*) desc`)
.limit(10),

// 5. Total attributed count
db.$count(applicationSource, eq(applicationSource.trackingLinkId, id)),
])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enforce orgId on the joined analytics queries.

After the initial trackingLink lookup, the rest of the handler is effectively scoped only by trackingLinkId. If an applicationSource row ever points at an application/job/candidate from another tenant, this endpoint will return that tenant's email and job metadata. Add organizationId = orgId predicates to the job lookup and the joined analytics queries so the tenant boundary is enforced here instead of relying on cross-table consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/tracking-links/`[id]/stats.get.ts around lines 30 - 127, The
analytics queries are only scoped by trackingLinkId and can leak other tenants'
data; add org scoping: include eq(job.organizationId, orgId) in the job lookup
inside job.findFirst, and ensure every analytics query uses the tenant
predicate(s) by adding eq(application.organizationId, orgId) and
eq(job.organizationId, orgId) (and eq(candidate.organizationId, orgId) for the
attributedApplications join) into the shared dateConditions/whereClause (or
combine with and(...) in each query) so statusBreakdown, dailyTrend,
attributedApplications, referrerDomains and totalAttributed are all filtered by
the current orgId.

Comment on lines +41 to +49
const dateConditions = [eq(applicationSource.trackingLinkId, id)]
if (query.from) {
dateConditions.push(gte(applicationSource.createdAt, new Date(query.from)))
}
if (query.to) {
dateConditions.push(lte(applicationSource.createdAt, new Date(query.to)))
}

const whereClause = and(...dateConditions)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The date-filtered response currently mixes filtered and all-time KPIs.

statusBreakdown, dailyTrend, attributedApplications, and referrerDomains honor from/to, but totalAttributed, link.applicationCount, link.clickCount, and the derived cvr are still all-time. With a 7d/30d filter selected, the dashboard can therefore show contradictory numbers for the same view. Either return period-scoped KPI fields here as well, or make these fields explicitly allTime* so the UI can separate the timeframes.

Also applies to: 125-163

@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-124 April 2, 2026 18:21 Destroyed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
app/pages/dashboard/jobs/[id]/index.vue (1)

455-490: ⚠️ Potential issue | 🟠 Major

Keep timeline state keyed to the requested candidate.

This still has the stale-response bug from the earlier review: resolvedCurrentApplication can point at the previous candidate while the next detail request is in flight, so loadTimeline() can fetch and commit the wrong history. After that, timelineLoaded flips to true and prevents the actual candidate from loading. Base timelineCandidateId on the currently loaded application only, and ignore any response whose candidate/request id no longer matches before assigning timelineItems.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/`[id]/index.vue around lines 455 - 490, The timeline
fetch can commit stale results because resolvedCurrentApplication may change
while a request is in flight; update loadTimeline and timelineCandidateId to key
state to the requested candidate by capturing the candidate id at request start
(e.g., const requestedId = resolvedCurrentApplication.value?.candidate?.id) and
returning early if missing, then after the $fetch completes verify that
resolvedCurrentApplication.value?.candidate?.id === requestedId before assigning
timelineItems, timelineLoaded, timelineError or timelineLoading; also ensure
timelineCandidateId is computed from the currently loaded application state only
and that any watcher (watch([detailTab, timelineCandidateId], ...)) uses that
keyed id to decide to call loadTimeline so stale responses are ignored.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/pages/dashboard/jobs/`[id]/index.vue:
- Around line 2499-2500: The template currently uses a truthy check (item.action
=== 'scored' && item.metadata?.score) which hides valid 0 scores; change the
condition to explicitly check for null/undefined (for example: item.action ===
'scored' && item.metadata?.score != null) or use a numeric check (e.g., typeof
item.metadata?.score === 'number' || Number.isFinite(item.metadata?.score')) so
the badge renders when score is 0; update the v-else-if in the template that
references item.action and item.metadata?.score accordingly.

---

Duplicate comments:
In `@app/pages/dashboard/jobs/`[id]/index.vue:
- Around line 455-490: The timeline fetch can commit stale results because
resolvedCurrentApplication may change while a request is in flight; update
loadTimeline and timelineCandidateId to key state to the requested candidate by
capturing the candidate id at request start (e.g., const requestedId =
resolvedCurrentApplication.value?.candidate?.id) and returning early if missing,
then after the $fetch completes verify that
resolvedCurrentApplication.value?.candidate?.id === requestedId before assigning
timelineItems, timelineLoaded, timelineError or timelineLoading; also ensure
timelineCandidateId is computed from the currently loaded application state only
and that any watcher (watch([detailTab, timelineCandidateId], ...)) uses that
keyed id to decide to call loadTimeline so stale responses are ignored.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 03a94af9-58ac-498b-90ca-f83fa58f99a2

📥 Commits

Reviewing files that changed from the base of the PR and between 46e1e15 and 475e643.

📒 Files selected for processing (1)
  • app/pages/dashboard/jobs/[id]/index.vue

Comment on lines +2499 to +2500
<template v-else-if="item.action === 'scored' && item.metadata?.score">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium leading-none bg-accent-100 text-accent-700 dark:bg-accent-900/60 dark:text-accent-300">{{ item.metadata.score }} pts</span>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don't hide valid 0 scores in the timeline.

This truthy check drops the badge for score = 0, even though 0 is a valid numeric score elsewhere on this page. Use a null check instead.

Suggested fix
-                          <template v-else-if="item.action === 'scored' && item.metadata?.score">
+                          <template v-else-if="item.action === 'scored' && item.metadata && item.metadata.score != null">
                             <span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium leading-none bg-accent-100 text-accent-700 dark:bg-accent-900/60 dark:text-accent-300">{{ item.metadata.score }} pts</span>
                           </template>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/`[id]/index.vue around lines 2499 - 2500, The
template currently uses a truthy check (item.action === 'scored' &&
item.metadata?.score) which hides valid 0 scores; change the condition to
explicitly check for null/undefined (for example: item.action === 'scored' &&
item.metadata?.score != null) or use a numeric check (e.g., typeof
item.metadata?.score === 'number' || Number.isFinite(item.metadata?.score')) so
the badge renders when score is 0; update the v-else-if in the template that
references item.action and item.metadata?.score accordingly.

- Updated `useSourceTracking` to include jobId in source stats.
- Implemented tracking links creation, deletion, and toggling in the application form.
- Enhanced the dashboard to display tracking links with their respective channels and statuses.
- Added source attribution records for applications in the seed script.
- Increased the limit for tracked applications in the stats API.
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-124 April 2, 2026 19:04 Destroyed
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-124 April 2, 2026 19:54 Destroyed
- In `renew-webhooks.post.ts`, implemented fixed-length buffers for comparing CRON secrets to prevent timing attacks.
- Updated error handling to throw a 403 status for invalid cron secrets.
- In `track/[code].get.ts`, added a check for the `BETTER_AUTH_URL` environment variable and throw a 500 error if misconfigured.
- Ensured `ref` parameter in redirect URLs is properly encoded to prevent potential issues with special characters.
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-124 April 2, 2026 20:13 Destroyed
…g code generation and enhance validation for tracking codes
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-124 April 3, 2026 16:21 Destroyed
@JoachimLK JoachimLK merged commit 9d60aaf into main Apr 3, 2026
5 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Apr 24, 2026
9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant